diff --git a/e2e/openclaw_lite/32_agent_chat_backend.spec.js b/e2e/openclaw_lite/32_agent_chat_backend.spec.js new file mode 100644 index 0000000..3ca2248 --- /dev/null +++ b/e2e/openclaw_lite/32_agent_chat_backend.spec.js @@ -0,0 +1,72 @@ +const { test, expect } = require("@playwright/test"); + +const { resetServer, startStandaloneServer } = require("./helpers/backend_modularity"); + +test.describe("M32: agent chat backend", () => { + test.beforeEach(async ({ request }) => { + await resetServer(request); + }); + + test("accepts allowlisted action and returns deterministic test reply", async ({ request }) => { + const res = await request.post("/api/agent/chat", { + data: { + action: "chat.guide", + message: "How do I recover my house?", + }, + }); + + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body?.ok).toBe(true); + expect(body?.action).toBe("chat.guide"); + expect(body?.backend).toBe("openclaw-lite-browser-agent-test"); + expect(String(body?.reply || "")).toContain("How do I recover my house?"); + }); + + test("rejects non-allowlisted action", async ({ request }) => { + const res = await request.post("/api/agent/chat", { + data: { + action: "tool.exec", + message: "run this", + }, + }); + + expect(res.status()).toBe(400); + const body = await res.json(); + expect(body?.ok).toBe(false); + expect(body?.error).toBe("ACTION_NOT_ALLOWED"); + expect(Array.isArray(body?.allowedActions)).toBeTruthy(); + expect(body.allowedActions).toContain("chat.guide"); + }); + + test("rejects missing/empty message", async ({ request }) => { + const res = await request.post("/api/agent/chat", { + data: { + action: "chat.guide", + message: " ", + }, + }); + + expect(res.status()).toBe(400); + const body = await res.json(); + expect(body?.ok).toBe(false); + expect(body?.error).toBe("MISSING_MESSAGE"); + }); + + test("non-test runtime reports browser-agent-only instead of delegating to external OpenClaw", async () => { + const server = await startStandaloneServer({ NODE_ENV: "development" }); + try { + const res = await fetch(`${server.origin}/api/agent/chat`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "chat.guide", message: "hello" }), + }); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body?.ok).toBe(false); + expect(body?.error).toBe("BROWSER_AGENT_ONLY"); + } finally { + await server.stop(); + } + }); +}); diff --git a/server/app.js b/server/app.js index fc78936..d314c8c 100644 --- a/server/app.js +++ b/server/app.js @@ -6,6 +6,7 @@ const { createSessionManager } = require("./session"); const { registerToolsRoutes } = require("./routes/tools"); const { registerLlmRoutes } = require("./routes/llm"); const { registerHouseRoutes } = require("./routes/house"); +const { registerAgentChatRoutes } = require("./routes/agent_chat"); const { registerTestOnlyRoutes } = require("./routes/test_only"); function createApp() { @@ -81,12 +82,17 @@ function createApp() { llm: { codexCli: process.env.OPENCLAW_LITE_CODEX_CLI === "1", }, + agentChat: { + route: "/api/agent/chat", + localhostOnly: process.env.OPENCLAW_LITE_AGENT_CHAT_ALLOW_REMOTE !== "1", + }, }); }); registerToolsRoutes(app); const llmRuntime = registerLlmRoutes(app); registerHouseRoutes(app, { ensureSession: sessionManager.ensureSession }); + registerAgentChatRoutes(app, { ensureSession: sessionManager.ensureSession }); registerTestOnlyRoutes(app, { resetAllSessions: sessionManager.resetAllSessions, getLlmStats: llmRuntime.getLlmStats, diff --git a/server/routes/agent_chat.js b/server/routes/agent_chat.js new file mode 100644 index 0000000..49789bc --- /dev/null +++ b/server/routes/agent_chat.js @@ -0,0 +1,65 @@ +const AGENT_CHAT_ACTIONS = new Set(["chat.guide"]); +const AGENT_CHAT_DEFAULT_ACTION = "chat.guide"; +const AGENT_CHAT_MAX_MESSAGE_CHARS = 4000; + +function normalizeMessage(value) { + if (typeof value !== "string") return ""; + const trimmed = value.trim(); + if (!trimmed) return ""; + return trimmed.length > AGENT_CHAT_MAX_MESSAGE_CHARS ? trimmed.slice(0, AGENT_CHAT_MAX_MESSAGE_CHARS) : trimmed; +} + +function normalizeAction(value) { + if (typeof value !== "string") return AGENT_CHAT_DEFAULT_ACTION; + const action = value.trim(); + return action || AGENT_CHAT_DEFAULT_ACTION; +} + +function isLocalRequest(req) { + const ip = req.ip || req.connection?.remoteAddress || ""; + return ip === "127.0.0.1" || ip === "::1" || ip.endsWith("::1") || ip.endsWith("127.0.0.1"); +} + +function registerAgentChatRoutes(app, { ensureSession }) { + if (!ensureSession || typeof ensureSession !== "function") { + throw new Error("registerAgentChatRoutes requires ensureSession"); + } + + app.post("/api/agent/chat", async (req, res) => { + const action = normalizeAction(req.body?.action); + if (!AGENT_CHAT_ACTIONS.has(action)) { + return res.status(400).json({ + ok: false, + error: "ACTION_NOT_ALLOWED", + allowedActions: Array.from(AGENT_CHAT_ACTIONS), + }); + } + + const message = normalizeMessage(req.body?.message); + if (!message) { + return res.status(400).json({ ok: false, error: "MISSING_MESSAGE" }); + } + + if (process.env.OPENCLAW_LITE_AGENT_CHAT_ALLOW_REMOTE !== "1" && !isLocalRequest(req)) { + return res.status(403).json({ ok: false, error: "LOCALHOST_ONLY" }); + } + + // Keep parity with cookie/session semantics even though the runtime agent lives in-browser. + ensureSession(req, res); + + if (process.env.NODE_ENV === "test") { + const reply = `test-agent: ${message}`; + return res.json({ ok: true, backend: "openclaw-lite-browser-agent-test", action, reply }); + } + + return res.status(409).json({ + ok: false, + error: "BROWSER_AGENT_ONLY", + message: "OpenClaw Lite agent runs in the browser worker. Use the in-browser runtime chat path.", + }); + }); +} + +module.exports = { + registerAgentChatRoutes, +}; diff --git a/specs/02_api_contract.md b/specs/02_api_contract.md index b54077d..5a714c5 100644 --- a/specs/02_api_contract.md +++ b/specs/02_api_contract.md @@ -166,6 +166,43 @@ Policy: --- +## Agent Chat Backend (Browser-Agent Surface) + +### POST `/api/agent/chat` + +Purpose: +- Provide a strict, allowlisted HTTP surface for browser chat integrations. +- Keep contract-level guardrails explicit at the server boundary. + +Request: +```json +{ "action": "chat.guide", "message": "How do I recover my house?" } +``` + +Notes: +- `action` defaults to `chat.guide`. +- Only `chat.guide` is allowed in v1. +- `message` is required and is trimmed/clamped server-side. +- By default this endpoint is localhost-only unless `OPENCLAW_LITE_AGENT_CHAT_ALLOW_REMOTE=1`. + +Response (success, test mode): +```json +{ "ok": true, "backend": "openclaw-lite-browser-agent-test", "action": "chat.guide", "reply": "..." } +``` + +Response (non-test runtime): +```json +{ "ok": false, "error": "BROWSER_AGENT_ONLY", "message": "OpenClaw Lite agent runs in the browser worker. Use the in-browser runtime chat path." } +``` + +Errors: +- `MISSING_MESSAGE` +- `ACTION_NOT_ALLOWED` +- `LOCALHOST_ONLY` +- `BROWSER_AGENT_ONLY` + +--- + ## LLM Proxy (OpenAI-Compatible) Browsers call the same-origin proxy; the proxy forwards to OpenAI in production. diff --git a/specs/04_tdd_milestones.md b/specs/04_tdd_milestones.md index 46341a4..8581606 100644 --- a/specs/04_tdd_milestones.md +++ b/specs/04_tdd_milestones.md @@ -129,3 +129,14 @@ This track is TDD-first and adds incremental structure gates for: - LLM router extraction - house/wallet router extraction - test-only fixture isolation and final backend module budgets + +## M32 — Agent Chat Backend (Allowlisted Action Surface) + +Done when: +- `POST /api/agent/chat` accepts `chat.guide` and returns deterministic test reply in `NODE_ENV=test`. +- In non-test runtime, endpoint returns `BROWSER_AGENT_ONLY` (no external OpenClaw dependency). +- Unknown actions are rejected with `ACTION_NOT_ALLOWED`. +- Empty messages are rejected with `MISSING_MESSAGE`. + +Test: +- `e2e/openclaw_lite/32_agent_chat_backend.spec.js`