import { expect, test } from "bun:test"; import express from "express"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { registerChatInteractionBridge } from "../../src/routes/chat-interaction-bridge.js"; import { setRuntimeSessionContext, removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js"; import { InteractionRequestStore } from "../../src/sessions/interaction-request-store.js"; import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js"; test("trusted gate binds host identity, rejects parallel tools and persists waiting before tool leave/idle", async () => { const root = await mkdtemp(join(tmpdir(), "interaction-bridge-")); const context = { sessionId: "bridge-session", clientSessionId: "bridge-session", actorKey: "trusted-actor", projectKey: "trusted-project", traceId: "test", hostTurnId: "host-turn", hostMessageId: "host-message", capabilities: ["host-question@1"], purpose: "ordinary_chat" as const }; const workspace = new SessionWorkspaceStore(root); await workspace.ensure(context); const interactions = new InteractionRequestStore(workspace); setRuntimeSessionContext(context); const app = express(); app.use(express.json()); registerChatInteractionBridge(app, { internalToken: "token", interactions }); const server = app.listen(0, "127.0.0.1"); await new Promise((resolve) => server.once("listening", resolve)); const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`; const post = (operation: string, body: unknown, headers: Record = {}) => fetch(`${base}/internal/chat-interactions/${operation}`, { method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": "token", ...headers }, body: JSON.stringify({ session_id: context.sessionId, ...body as object }) }); const ask = { tool: "ask_user", call_id: "ask" }; const query = { tool: "mcp_query", call_id: "query" }; const questions = [{ question_id: "q", title: "Title", body: "Question", options: [], mode: "single", allow_custom: true, required: true }]; try { expect((await post("enter", ask, { "x-agent-internal-token": "wrong" })).status).toBe(403); expect((await post("enter", ask, { Origin: "https://browser.test" })).status).toBe(403); expect((await post("create", { ...ask, questions, actorKey: "forged" })).status).toBe(400); expect((await post("create", { ...ask, questions })).status).toBe(409); expect((await post("enter", query)).status).toBe(200); expect((await post("enter", ask)).status).toBe(409); await post("leave", query); expect((await post("enter", ask)).status).toBe(200); expect((await post("enter", query)).status).toBe(409); const response = await post("create", { ...ask, questions }); expect(response.status).toBe(200); const record = await response.json(); expect(record).toMatchObject({ message_id: "host-message", turn_id: "host-turn", call_id: "ask", status: "pending" }); expect(await interactions.get(context, record.interaction_id)).toEqual(record); expect(await (await post("policy", {})).json()).toEqual({ enabled: true, waiting: true }); await post("leave", ask); expect((await post("enter", query)).status).toBe(409); expect((await post("enter", { tool: "question", call_id: "native" })).status).toBe(409); await interactions.cancelPending(context); expect((await post("enter", query)).status).toBe(409); setRuntimeSessionContext({ ...context, hostTurnId: "next-turn" }); expect((await post("enter", query)).status).toBe(200); } finally { removeRuntimeSessionContext(context.sessionId); await new Promise((resolve) => server.close(() => resolve())); await rm(root, { recursive: true, force: true }); } });