From 986fe607811a2c8935f7396bcb0aaed640a6d764 Mon Sep 17 00:00:00 2001 From: Robin <4robinlehmann@gmail.com> Date: Wed, 11 Feb 2026 19:00:26 +0700 Subject: [PATCH 1/2] feat: add allowlisted /api/agent/chat OpenClaw bridge --- .../32_agent_chat_backend.spec.js | 55 ++++++ server/app.js | 6 + server/routes/agent_chat.js | 157 ++++++++++++++++++ specs/02_api_contract.md | 33 ++++ specs/04_tdd_milestones.md | 10 ++ 5 files changed, 261 insertions(+) create mode 100644 e2e/openclaw_lite/32_agent_chat_backend.spec.js create mode 100644 server/routes/agent_chat.js 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..236b5ef --- /dev/null +++ b/e2e/openclaw_lite/32_agent_chat_backend.spec.js @@ -0,0 +1,55 @@ +const { test, expect } = require("@playwright/test"); + +const { resetServer } = 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-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"); + }); +}); 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..ea8714f --- /dev/null +++ b/server/routes/agent_chat.js @@ -0,0 +1,157 @@ +const { execFile } = require("child_process"); +const { promisify } = require("util"); + +const execFileAsync = promisify(execFile); + +const AGENT_CHAT_ACTIONS = new Set(["chat.guide"]); +const AGENT_CHAT_DEFAULT_ACTION = "chat.guide"; +const AGENT_CHAT_MAX_MESSAGE_CHARS = 4000; +const AGENT_CHAT_TIMEOUT_SECONDS = 45; + +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 buildOpenClawSessionId(sessionId) { + const safe = String(sessionId || "anon") + .replace(/[^a-zA-Z0-9._-]/g, "_") + .slice(0, 80); + return `openclaw-lite-agent-${safe}`; +} + +function buildPrompt({ userMessage, houseId }) { + return [ + "You are the OpenClaw Lite in-app assistant.", + "Hard constraints:", + "- Chat-only guidance. Do not execute tools/actions.", + "- Do not claim to have performed external side effects.", + "- If asked to perform actions, provide safe step-by-step guidance instead.", + "- Keep responses concise and practical.", + `Context: houseId=${houseId || "none"}`, + "", + "User message:", + userMessage, + ].join("\n"); +} + +function extractJsonObjectFromOutput(stdout) { + const text = String(stdout || "").trim(); + if (!text) throw new Error("EMPTY_OUTPUT"); + + try { + return JSON.parse(text); + } catch { + // Continue to tolerant parsing. + } + + const lines = text.split(/\r?\n/); + for (let i = 0; i < lines.length; i += 1) { + if (!lines[i].trim().startsWith("{")) continue; + const candidate = lines.slice(i).join("\n"); + try { + return JSON.parse(candidate); + } catch { + // continue + } + } + + throw new Error("INVALID_JSON_OUTPUT"); +} + +function extractAgentReply(json) { + const payloads = json?.result?.payloads; + if (Array.isArray(payloads)) { + for (const payload of payloads) { + if (payload && typeof payload.text === "string" && payload.text.trim()) return payload.text.trim(); + } + } + return ""; +} + +async function runOpenClawAgent({ sessionId, prompt }) { + const openclawBin = process.env.OPENCLAW_LITE_AGENT_CLI || "openclaw"; + const timeoutSeconds = Math.max(5, Number(process.env.OPENCLAW_LITE_AGENT_TIMEOUT_SECONDS || AGENT_CHAT_TIMEOUT_SECONDS)); + + const args = [ + "agent", + "--session-id", + sessionId, + "--message", + prompt, + "--timeout", + String(timeoutSeconds), + "--json", + ]; + + const { stdout } = await execFileAsync(openclawBin, args, { + timeout: timeoutSeconds * 1000 + 2000, + maxBuffer: 1024 * 1024, + env: { ...process.env }, + }); + + const parsed = extractJsonObjectFromOutput(stdout); + const reply = extractAgentReply(parsed); + if (!reply) throw new Error("EMPTY_AGENT_REPLY"); + return reply; +} + +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" }); + } + + const session = ensureSession(req, res); + const sessionId = buildOpenClawSessionId(session?.sessionId); + const prompt = buildPrompt({ userMessage: message, houseId: session?.houseId || null }); + + if (process.env.NODE_ENV === "test") { + const reply = `test-agent: ${message}`; + return res.json({ ok: true, backend: "openclaw-agent-test", action, reply }); + } + + try { + const reply = await runOpenClawAgent({ sessionId, prompt }); + return res.json({ ok: true, backend: "openclaw-agent", action, reply }); + } catch (error) { + const code = error?.code === "ENOENT" ? "OPENCLAW_CLI_NOT_FOUND" : "AGENT_BACKEND_FAILED"; + return res.status(502).json({ ok: false, error: code }); + } + }); +} + +module.exports = { + registerAgentChatRoutes, +}; diff --git a/specs/02_api_contract.md b/specs/02_api_contract.md index b54077d..71719e8 100644 --- a/specs/02_api_contract.md +++ b/specs/02_api_contract.md @@ -166,6 +166,39 @@ Policy: --- +## Agent Chat Backend (OpenClaw Session Bridge) + +### POST `/api/agent/chat` + +Purpose: +- Provide a server-backed assistant reply path for UI surfaces that need a real OpenClaw session turn. +- Enforce an explicit action allowlist at the HTTP 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): +```json +{ "ok": true, "backend": "openclaw-agent", "action": "chat.guide", "reply": "..." } +``` + +Errors: +- `MISSING_MESSAGE` +- `ACTION_NOT_ALLOWED` +- `LOCALHOST_ONLY` +- `OPENCLAW_CLI_NOT_FOUND` +- `AGENT_BACKEND_FAILED` + +--- + ## 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..96a2721 100644 --- a/specs/04_tdd_milestones.md +++ b/specs/04_tdd_milestones.md @@ -129,3 +129,13 @@ 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`. +- 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` From a879fc2c737e9ba91cf1e8babb607f459422d975 Mon Sep 17 00:00:00 2001 From: Robin <4robinlehmann@gmail.com> Date: Wed, 11 Feb 2026 19:05:45 +0700 Subject: [PATCH 2/2] fix: keep /api/agent/chat browser-agent only --- .../32_agent_chat_backend.spec.js | 21 +++- server/routes/agent_chat.js | 108 ++---------------- specs/02_api_contract.md | 18 +-- specs/04_tdd_milestones.md | 1 + 4 files changed, 39 insertions(+), 109 deletions(-) diff --git a/e2e/openclaw_lite/32_agent_chat_backend.spec.js b/e2e/openclaw_lite/32_agent_chat_backend.spec.js index 236b5ef..3ca2248 100644 --- a/e2e/openclaw_lite/32_agent_chat_backend.spec.js +++ b/e2e/openclaw_lite/32_agent_chat_backend.spec.js @@ -1,6 +1,6 @@ const { test, expect } = require("@playwright/test"); -const { resetServer } = require("./helpers/backend_modularity"); +const { resetServer, startStandaloneServer } = require("./helpers/backend_modularity"); test.describe("M32: agent chat backend", () => { test.beforeEach(async ({ request }) => { @@ -19,7 +19,7 @@ test.describe("M32: agent chat backend", () => { const body = await res.json(); expect(body?.ok).toBe(true); expect(body?.action).toBe("chat.guide"); - expect(body?.backend).toBe("openclaw-agent-test"); + expect(body?.backend).toBe("openclaw-lite-browser-agent-test"); expect(String(body?.reply || "")).toContain("How do I recover my house?"); }); @@ -52,4 +52,21 @@ test.describe("M32: agent chat backend", () => { 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/routes/agent_chat.js b/server/routes/agent_chat.js index ea8714f..49789bc 100644 --- a/server/routes/agent_chat.js +++ b/server/routes/agent_chat.js @@ -1,12 +1,6 @@ -const { execFile } = require("child_process"); -const { promisify } = require("util"); - -const execFileAsync = promisify(execFile); - const AGENT_CHAT_ACTIONS = new Set(["chat.guide"]); const AGENT_CHAT_DEFAULT_ACTION = "chat.guide"; const AGENT_CHAT_MAX_MESSAGE_CHARS = 4000; -const AGENT_CHAT_TIMEOUT_SECONDS = 45; function normalizeMessage(value) { if (typeof value !== "string") return ""; @@ -26,89 +20,6 @@ function isLocalRequest(req) { return ip === "127.0.0.1" || ip === "::1" || ip.endsWith("::1") || ip.endsWith("127.0.0.1"); } -function buildOpenClawSessionId(sessionId) { - const safe = String(sessionId || "anon") - .replace(/[^a-zA-Z0-9._-]/g, "_") - .slice(0, 80); - return `openclaw-lite-agent-${safe}`; -} - -function buildPrompt({ userMessage, houseId }) { - return [ - "You are the OpenClaw Lite in-app assistant.", - "Hard constraints:", - "- Chat-only guidance. Do not execute tools/actions.", - "- Do not claim to have performed external side effects.", - "- If asked to perform actions, provide safe step-by-step guidance instead.", - "- Keep responses concise and practical.", - `Context: houseId=${houseId || "none"}`, - "", - "User message:", - userMessage, - ].join("\n"); -} - -function extractJsonObjectFromOutput(stdout) { - const text = String(stdout || "").trim(); - if (!text) throw new Error("EMPTY_OUTPUT"); - - try { - return JSON.parse(text); - } catch { - // Continue to tolerant parsing. - } - - const lines = text.split(/\r?\n/); - for (let i = 0; i < lines.length; i += 1) { - if (!lines[i].trim().startsWith("{")) continue; - const candidate = lines.slice(i).join("\n"); - try { - return JSON.parse(candidate); - } catch { - // continue - } - } - - throw new Error("INVALID_JSON_OUTPUT"); -} - -function extractAgentReply(json) { - const payloads = json?.result?.payloads; - if (Array.isArray(payloads)) { - for (const payload of payloads) { - if (payload && typeof payload.text === "string" && payload.text.trim()) return payload.text.trim(); - } - } - return ""; -} - -async function runOpenClawAgent({ sessionId, prompt }) { - const openclawBin = process.env.OPENCLAW_LITE_AGENT_CLI || "openclaw"; - const timeoutSeconds = Math.max(5, Number(process.env.OPENCLAW_LITE_AGENT_TIMEOUT_SECONDS || AGENT_CHAT_TIMEOUT_SECONDS)); - - const args = [ - "agent", - "--session-id", - sessionId, - "--message", - prompt, - "--timeout", - String(timeoutSeconds), - "--json", - ]; - - const { stdout } = await execFileAsync(openclawBin, args, { - timeout: timeoutSeconds * 1000 + 2000, - maxBuffer: 1024 * 1024, - env: { ...process.env }, - }); - - const parsed = extractJsonObjectFromOutput(stdout); - const reply = extractAgentReply(parsed); - if (!reply) throw new Error("EMPTY_AGENT_REPLY"); - return reply; -} - function registerAgentChatRoutes(app, { ensureSession }) { if (!ensureSession || typeof ensureSession !== "function") { throw new Error("registerAgentChatRoutes requires ensureSession"); @@ -133,22 +44,19 @@ function registerAgentChatRoutes(app, { ensureSession }) { return res.status(403).json({ ok: false, error: "LOCALHOST_ONLY" }); } - const session = ensureSession(req, res); - const sessionId = buildOpenClawSessionId(session?.sessionId); - const prompt = buildPrompt({ userMessage: message, houseId: session?.houseId || null }); + // 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-agent-test", action, reply }); + return res.json({ ok: true, backend: "openclaw-lite-browser-agent-test", action, reply }); } - try { - const reply = await runOpenClawAgent({ sessionId, prompt }); - return res.json({ ok: true, backend: "openclaw-agent", action, reply }); - } catch (error) { - const code = error?.code === "ENOENT" ? "OPENCLAW_CLI_NOT_FOUND" : "AGENT_BACKEND_FAILED"; - return res.status(502).json({ ok: false, error: code }); - } + 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.", + }); }); } diff --git a/specs/02_api_contract.md b/specs/02_api_contract.md index 71719e8..5a714c5 100644 --- a/specs/02_api_contract.md +++ b/specs/02_api_contract.md @@ -166,13 +166,13 @@ Policy: --- -## Agent Chat Backend (OpenClaw Session Bridge) +## Agent Chat Backend (Browser-Agent Surface) ### POST `/api/agent/chat` Purpose: -- Provide a server-backed assistant reply path for UI surfaces that need a real OpenClaw session turn. -- Enforce an explicit action allowlist at the HTTP boundary. +- Provide a strict, allowlisted HTTP surface for browser chat integrations. +- Keep contract-level guardrails explicit at the server boundary. Request: ```json @@ -185,17 +185,21 @@ Notes: - `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): +Response (success, test mode): ```json -{ "ok": true, "backend": "openclaw-agent", "action": "chat.guide", "reply": "..." } +{ "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` -- `OPENCLAW_CLI_NOT_FOUND` -- `AGENT_BACKEND_FAILED` +- `BROWSER_AGENT_ONLY` --- diff --git a/specs/04_tdd_milestones.md b/specs/04_tdd_milestones.md index 96a2721..8581606 100644 --- a/specs/04_tdd_milestones.md +++ b/specs/04_tdd_milestones.md @@ -134,6 +134,7 @@ This track is TDD-first and adds incremental structure gates for: 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`.