Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions e2e/openclaw_lite/32_agent_chat_backend.spec.js
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
6 changes: 6 additions & 0 deletions server/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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,
Expand Down
65 changes: 65 additions & 0 deletions server/routes/agent_chat.js
Original file line number Diff line number Diff line change
@@ -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,
};
37 changes: 37 additions & 0 deletions specs/02_api_contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions specs/04_tdd_milestones.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`