@zooid/transport-http 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +219 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
- package/src/context.test.ts +8 -0
- package/src/context.ts +11 -0
- package/src/index.ts +3 -0
- package/src/server.test.ts +421 -0
- package/src/server.ts +303 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zooid contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as hono_types from 'hono/types';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { AcpRegistry, ApprovalCorrelator, TransportContextProvider } from '@zooid/core';
|
|
4
|
+
|
|
5
|
+
interface CreateAppOptions {
|
|
6
|
+
/** Long-lived registry that fronts every ACP agent. */
|
|
7
|
+
agents: AcpRegistry;
|
|
8
|
+
/**
|
|
9
|
+
* Correlator that pairs ACP `requestPermission` calls with HTTP-side
|
|
10
|
+
* decisions. The transport listens to its `'registered'` and `'timeout'`
|
|
11
|
+
* events to drive the SSE wire and resolves it via the POST decision route.
|
|
12
|
+
*/
|
|
13
|
+
approvals: ApprovalCorrelator;
|
|
14
|
+
/** Bearer token; constant-time compared against `Authorization`. */
|
|
15
|
+
token: string;
|
|
16
|
+
/**
|
|
17
|
+
* Keepalive interval for open SSE streams (proxies kill idle connections).
|
|
18
|
+
* 0 disables. Default: 30_000ms.
|
|
19
|
+
*/
|
|
20
|
+
keepaliveMs?: number;
|
|
21
|
+
}
|
|
22
|
+
declare function createApp({ agents, approvals, token, keepaliveMs, }: CreateAppOptions): Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* HTTP transport owns no durable conversation context in MVP. Returning null
|
|
26
|
+
* tells the daemon to omit `zooid-context` from `session/new mcpServers` so
|
|
27
|
+
* the shim never surfaces tools that would return nothing. See [ZOD046] for
|
|
28
|
+
* the follow-on that would change this.
|
|
29
|
+
*/
|
|
30
|
+
declare function getContextProvider(): TransportContextProvider | null;
|
|
31
|
+
|
|
32
|
+
export { type CreateAppOptions, createApp, getContextProvider };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// src/server.ts
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { streamSSE } from "hono/streaming";
|
|
4
|
+
import { timingSafeEqual, randomUUID } from "crypto";
|
|
5
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
6
|
+
function isAuthorized(token, header) {
|
|
7
|
+
if (!header || !header.startsWith("Bearer ")) return false;
|
|
8
|
+
const provided = header.slice("Bearer ".length);
|
|
9
|
+
if (provided.length !== token.length) return false;
|
|
10
|
+
return timingSafeEqual(Buffer.from(provided), Buffer.from(token));
|
|
11
|
+
}
|
|
12
|
+
function parsePromptBody(raw) {
|
|
13
|
+
if (!raw || typeof raw !== "object") return { error: "body must be a JSON object" };
|
|
14
|
+
const obj = raw;
|
|
15
|
+
if (typeof obj.prompt !== "string" || obj.prompt.length === 0) {
|
|
16
|
+
return { error: "missing or empty prompt" };
|
|
17
|
+
}
|
|
18
|
+
return { prompt: obj.prompt };
|
|
19
|
+
}
|
|
20
|
+
async function readJson(c) {
|
|
21
|
+
const raw = await c.req.text();
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(raw);
|
|
24
|
+
} catch {
|
|
25
|
+
return { error: "body must be valid JSON" };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function parseDecisionBody(raw) {
|
|
29
|
+
if (!raw || typeof raw !== "object") return { error: "body must be a JSON object" };
|
|
30
|
+
const obj = raw;
|
|
31
|
+
if (obj.decision !== "allow" && obj.decision !== "cancel") {
|
|
32
|
+
return { error: 'decision must be "allow" or "cancel"' };
|
|
33
|
+
}
|
|
34
|
+
if (obj.decision === "allow" && typeof obj.option_id !== "string") {
|
|
35
|
+
return { error: 'option_id is required when decision is "allow"' };
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
decision: obj.decision,
|
|
39
|
+
option_id: typeof obj.option_id === "string" ? obj.option_id : void 0
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function approvalRequestFrame(handle) {
|
|
43
|
+
return JSON.stringify({
|
|
44
|
+
type: "approval.request",
|
|
45
|
+
approval_id: handle.approvalId,
|
|
46
|
+
session_id: handle.sessionId,
|
|
47
|
+
tool_call_id: handle.toolCallId,
|
|
48
|
+
options: handle.options
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function createApp({
|
|
52
|
+
agents,
|
|
53
|
+
approvals,
|
|
54
|
+
token,
|
|
55
|
+
keepaliveMs = 3e4
|
|
56
|
+
}) {
|
|
57
|
+
const app = new Hono();
|
|
58
|
+
const streams = /* @__PURE__ */ new Map();
|
|
59
|
+
agents.onEvent = (_name, event) => {
|
|
60
|
+
const stream = streams.get(event.sessionId);
|
|
61
|
+
if (!stream) return;
|
|
62
|
+
void stream.writeSSE({ data: JSON.stringify(event) });
|
|
63
|
+
};
|
|
64
|
+
agents.onApprovalRequest = async (name, req) => {
|
|
65
|
+
const handle = approvals.register(name, req.sessionId, req, {
|
|
66
|
+
timeoutMs: agents.getApprovalTimeoutMs(name)
|
|
67
|
+
});
|
|
68
|
+
return handle.decisionPromise;
|
|
69
|
+
};
|
|
70
|
+
approvals.on("registered", (handle) => {
|
|
71
|
+
const stream = streams.get(handle.sessionId);
|
|
72
|
+
if (!stream) return;
|
|
73
|
+
void stream.writeSSE({ data: approvalRequestFrame(handle) });
|
|
74
|
+
});
|
|
75
|
+
approvals.on("timeout", ({ approvalId, sessionId }) => {
|
|
76
|
+
const stream = streams.get(sessionId);
|
|
77
|
+
if (!stream) return;
|
|
78
|
+
void stream.writeSSE({
|
|
79
|
+
data: JSON.stringify({ type: "approval.timeout", approval_id: approvalId, session_id: sessionId })
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
function checkAgent(c, name) {
|
|
83
|
+
if (!agents.hasAgent(name)) {
|
|
84
|
+
return c.json({ error: "unknown agent" }, 404);
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
function attachKeepalive(sse) {
|
|
89
|
+
if (!keepaliveMs || keepaliveMs <= 0) return null;
|
|
90
|
+
const timer = setInterval(() => {
|
|
91
|
+
void sse.writeSSE({ data: "", event: "keepalive" }).catch(() => {
|
|
92
|
+
});
|
|
93
|
+
}, keepaliveMs);
|
|
94
|
+
timer.unref?.();
|
|
95
|
+
return timer;
|
|
96
|
+
}
|
|
97
|
+
app.post("/agents/:name/sessions", async (c) => {
|
|
98
|
+
if (!isAuthorized(token, c.req.header("authorization"))) {
|
|
99
|
+
return c.json({ error: "unauthorized" }, 401);
|
|
100
|
+
}
|
|
101
|
+
const name = c.req.param("name");
|
|
102
|
+
const reject = checkAgent(c, name);
|
|
103
|
+
if (reject) return reject;
|
|
104
|
+
const parsed = await readJson(c);
|
|
105
|
+
if (parsed && typeof parsed === "object" && "error" in parsed) {
|
|
106
|
+
return c.json({ error: parsed.error }, 400);
|
|
107
|
+
}
|
|
108
|
+
const body = parsePromptBody(parsed);
|
|
109
|
+
if ("error" in body) return c.json({ error: body.error }, 400);
|
|
110
|
+
const threadId = randomUUID();
|
|
111
|
+
return streamSSE(c, async (sse) => {
|
|
112
|
+
let sessionId = null;
|
|
113
|
+
let keepalive = null;
|
|
114
|
+
try {
|
|
115
|
+
sessionId = await agents.ensureSession(name, threadId);
|
|
116
|
+
streams.set(sessionId, sse);
|
|
117
|
+
keepalive = attachKeepalive(sse);
|
|
118
|
+
await sse.writeSSE({
|
|
119
|
+
data: JSON.stringify({ type: "session.start", session_id: sessionId })
|
|
120
|
+
});
|
|
121
|
+
await sse.writeSSE({ data: JSON.stringify({ type: "turn.start" }) });
|
|
122
|
+
const result = await agents.prompt(name, {
|
|
123
|
+
threadId,
|
|
124
|
+
content: [{ type: "text", text: body.prompt }]
|
|
125
|
+
});
|
|
126
|
+
await sse.writeSSE({
|
|
127
|
+
data: JSON.stringify({ type: "turn.end", stop_reason: result.stopReason })
|
|
128
|
+
});
|
|
129
|
+
} catch (err) {
|
|
130
|
+
await sse.writeSSE({
|
|
131
|
+
data: JSON.stringify({
|
|
132
|
+
type: "turn.end",
|
|
133
|
+
stop_reason: "error",
|
|
134
|
+
error: err.message
|
|
135
|
+
})
|
|
136
|
+
});
|
|
137
|
+
} finally {
|
|
138
|
+
if (keepalive) clearInterval(keepalive);
|
|
139
|
+
if (sessionId) {
|
|
140
|
+
approvals.cancelSession(sessionId);
|
|
141
|
+
if (streams.get(sessionId) === sse) {
|
|
142
|
+
streams.delete(sessionId);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
app.get("/agents/:name/sessions/:id/events", async (c) => {
|
|
149
|
+
if (!isAuthorized(token, c.req.header("authorization"))) {
|
|
150
|
+
return c.json({ error: "unauthorized" }, 401);
|
|
151
|
+
}
|
|
152
|
+
const name = c.req.param("name");
|
|
153
|
+
const reject = checkAgent(c, name);
|
|
154
|
+
if (reject) return reject;
|
|
155
|
+
const id = c.req.param("id");
|
|
156
|
+
if (!UUID_RE.test(id)) {
|
|
157
|
+
return c.json({ error: "session id must be a UUID" }, 400);
|
|
158
|
+
}
|
|
159
|
+
if (!streams.has(id)) {
|
|
160
|
+
return c.json({ error: "no in-flight session" }, 404);
|
|
161
|
+
}
|
|
162
|
+
return streamSSE(c, async (sse) => {
|
|
163
|
+
streams.set(id, sse);
|
|
164
|
+
const keepalive = attachKeepalive(sse);
|
|
165
|
+
for (const handle of approvals.listPending(id)) {
|
|
166
|
+
await sse.writeSSE({ data: approvalRequestFrame(handle) });
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
while (streams.get(id) === sse) {
|
|
170
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
171
|
+
}
|
|
172
|
+
} finally {
|
|
173
|
+
if (keepalive) clearInterval(keepalive);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
app.post("/agents/:name/sessions/:sid/approvals/:approval_id", async (c) => {
|
|
178
|
+
if (!isAuthorized(token, c.req.header("authorization"))) {
|
|
179
|
+
return c.json({ error: "unauthorized" }, 401);
|
|
180
|
+
}
|
|
181
|
+
const name = c.req.param("name");
|
|
182
|
+
const reject = checkAgent(c, name);
|
|
183
|
+
if (reject) return reject;
|
|
184
|
+
const sid = c.req.param("sid");
|
|
185
|
+
const approvalId = c.req.param("approval_id");
|
|
186
|
+
const parsed = await readJson(c);
|
|
187
|
+
if (parsed && typeof parsed === "object" && "error" in parsed) {
|
|
188
|
+
return c.json({ error: parsed.error }, 400);
|
|
189
|
+
}
|
|
190
|
+
const body = parseDecisionBody(parsed);
|
|
191
|
+
if ("error" in body) return c.json({ error: body.error }, 400);
|
|
192
|
+
const decision = body.decision === "cancel" ? { decision: "cancel" } : { decision: "allow", optionId: body.option_id };
|
|
193
|
+
const ok = approvals.resolve(sid, approvalId, decision);
|
|
194
|
+
if (!ok) return c.json({ error: "unknown approval" }, 404);
|
|
195
|
+
return c.json({ ok: true });
|
|
196
|
+
});
|
|
197
|
+
app.post("/agents/:name/sessions/:sid/cancel", async (c) => {
|
|
198
|
+
if (!isAuthorized(token, c.req.header("authorization"))) {
|
|
199
|
+
return c.json({ error: "unauthorized" }, 401);
|
|
200
|
+
}
|
|
201
|
+
const name = c.req.param("name");
|
|
202
|
+
const reject = checkAgent(c, name);
|
|
203
|
+
if (reject) return reject;
|
|
204
|
+
const sid = c.req.param("sid");
|
|
205
|
+
approvals.cancelSession(sid);
|
|
206
|
+
return c.body(null, 204);
|
|
207
|
+
});
|
|
208
|
+
return app;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/context.ts
|
|
212
|
+
function getContextProvider() {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
export {
|
|
216
|
+
createApp,
|
|
217
|
+
getContextProvider
|
|
218
|
+
};
|
|
219
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/server.ts","../src/context.ts"],"sourcesContent":["import { Hono, type Context } from 'hono'\nimport { streamSSE, type SSEStreamingApi } from 'hono/streaming'\nimport { timingSafeEqual, randomUUID } from 'node:crypto'\nimport type {\n AcpRegistry,\n ApprovalCorrelator,\n RegisteredApproval,\n} from '@zooid/core'\nimport type {\n AgentEvent,\n ApprovalDecision,\n} from '@zooid/acp-client'\n\nexport interface CreateAppOptions {\n /** Long-lived registry that fronts every ACP agent. */\n agents: AcpRegistry\n /**\n * Correlator that pairs ACP `requestPermission` calls with HTTP-side\n * decisions. The transport listens to its `'registered'` and `'timeout'`\n * events to drive the SSE wire and resolves it via the POST decision route.\n */\n approvals: ApprovalCorrelator\n /** Bearer token; constant-time compared against `Authorization`. */\n token: string\n /**\n * Keepalive interval for open SSE streams (proxies kill idle connections).\n * 0 disables. Default: 30_000ms.\n */\n keepaliveMs?: number\n}\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/\n\ninterface PromptBody {\n prompt: string\n}\n\nfunction isAuthorized(token: string, header: string | undefined): boolean {\n if (!header || !header.startsWith('Bearer ')) return false\n const provided = header.slice('Bearer '.length)\n if (provided.length !== token.length) return false\n return timingSafeEqual(Buffer.from(provided), Buffer.from(token))\n}\n\nfunction parsePromptBody(raw: unknown): PromptBody | { error: string } {\n if (!raw || typeof raw !== 'object') return { error: 'body must be a JSON object' }\n const obj = raw as Record<string, unknown>\n if (typeof obj.prompt !== 'string' || obj.prompt.length === 0) {\n return { error: 'missing or empty prompt' }\n }\n return { prompt: obj.prompt }\n}\n\nasync function readJson(c: Context): Promise<unknown | { error: string }> {\n const raw = await c.req.text()\n try {\n return JSON.parse(raw)\n } catch {\n return { error: 'body must be valid JSON' }\n }\n}\n\ninterface DecisionBody {\n decision: 'allow' | 'cancel'\n option_id?: string\n}\n\nfunction parseDecisionBody(raw: unknown): DecisionBody | { error: string } {\n if (!raw || typeof raw !== 'object') return { error: 'body must be a JSON object' }\n const obj = raw as Record<string, unknown>\n if (obj.decision !== 'allow' && obj.decision !== 'cancel') {\n return { error: 'decision must be \"allow\" or \"cancel\"' }\n }\n if (obj.decision === 'allow' && typeof obj.option_id !== 'string') {\n return { error: 'option_id is required when decision is \"allow\"' }\n }\n return {\n decision: obj.decision,\n option_id: typeof obj.option_id === 'string' ? obj.option_id : undefined,\n }\n}\n\nfunction approvalRequestFrame(handle: RegisteredApproval): string {\n return JSON.stringify({\n type: 'approval.request',\n approval_id: handle.approvalId,\n session_id: handle.sessionId,\n tool_call_id: handle.toolCallId,\n options: handle.options,\n })\n}\n\nexport function createApp({\n agents,\n approvals,\n token,\n keepaliveMs = 30_000,\n}: CreateAppOptions) {\n const app = new Hono()\n\n // Per-session SSE handle. The registry's onEvent dispatcher and the\n // correlator's `'registered'` / `'timeout'` listeners look up the\n // currently-attached stream by session id.\n const streams = new Map<string, SSEStreamingApi>()\n\n agents.onEvent = (_name, event: AgentEvent) => {\n const stream = streams.get(event.sessionId)\n if (!stream) return\n void stream.writeSSE({ data: JSON.stringify(event) })\n }\n // Always wire the registry's approval handler to the correlator. The\n // per-agent `approval_timeout_ms` is read off the registry so YAML-driven\n // timeouts still apply.\n agents.onApprovalRequest = async (name, req) => {\n const handle = approvals.register(name, req.sessionId, req, {\n timeoutMs: agents.getApprovalTimeoutMs(name),\n })\n return handle.decisionPromise\n }\n\n approvals.on('registered', (handle: RegisteredApproval) => {\n const stream = streams.get(handle.sessionId)\n if (!stream) return\n void stream.writeSSE({ data: approvalRequestFrame(handle) })\n })\n\n approvals.on('timeout', ({ approvalId, sessionId }: { approvalId: string; sessionId: string }) => {\n const stream = streams.get(sessionId)\n if (!stream) return\n void stream.writeSSE({\n data: JSON.stringify({ type: 'approval.timeout', approval_id: approvalId, session_id: sessionId }),\n })\n })\n\n function checkAgent(c: Context, name: string): Response | null {\n if (!agents.hasAgent(name)) {\n return c.json({ error: 'unknown agent' }, 404)\n }\n return null\n }\n\n function attachKeepalive(sse: SSEStreamingApi): NodeJS.Timeout | null {\n if (!keepaliveMs || keepaliveMs <= 0) return null\n const timer = setInterval(() => {\n void sse.writeSSE({ data: '', event: 'keepalive' }).catch(() => {})\n }, keepaliveMs)\n timer.unref?.()\n return timer\n }\n\n /**\n * POST /agents/:name/sessions — start a new ACP session.\n *\n * { session.start, session_id }\n * { turn.start }\n * ... ACP AgentEvents\n * { approval.request, ... } / { approval.timeout, ... } as they arrive\n * { turn.end, stop_reason }\n */\n app.post('/agents/:name/sessions', async (c) => {\n if (!isAuthorized(token, c.req.header('authorization'))) {\n return c.json({ error: 'unauthorized' }, 401)\n }\n const name = c.req.param('name')\n const reject = checkAgent(c, name)\n if (reject) return reject\n\n const parsed = await readJson(c)\n if (parsed && typeof parsed === 'object' && 'error' in parsed) {\n return c.json({ error: (parsed as { error: string }).error }, 400)\n }\n const body = parsePromptBody(parsed)\n if ('error' in body) return c.json({ error: body.error }, 400)\n\n const threadId = randomUUID()\n return streamSSE(c, async (sse) => {\n let sessionId: string | null = null\n let keepalive: NodeJS.Timeout | null = null\n try {\n sessionId = await agents.ensureSession(name, threadId)\n streams.set(sessionId, sse)\n keepalive = attachKeepalive(sse)\n await sse.writeSSE({\n data: JSON.stringify({ type: 'session.start', session_id: sessionId }),\n })\n await sse.writeSSE({ data: JSON.stringify({ type: 'turn.start' }) })\n const result = await agents.prompt(name, {\n threadId,\n content: [{ type: 'text', text: body.prompt }],\n })\n await sse.writeSSE({\n data: JSON.stringify({ type: 'turn.end', stop_reason: result.stopReason }),\n })\n } catch (err) {\n await sse.writeSSE({\n data: JSON.stringify({\n type: 'turn.end',\n stop_reason: 'error',\n error: (err as Error).message,\n }),\n })\n } finally {\n if (keepalive) clearInterval(keepalive)\n if (sessionId) {\n // The turn is over; cancel any approvals that the shim left\n // dangling so future POSTs against them 404 fast.\n approvals.cancelSession(sessionId)\n if (streams.get(sessionId) === sse) {\n streams.delete(sessionId)\n }\n }\n }\n })\n })\n\n /**\n * GET /agents/:name/sessions/:id/events — reattach to an in-flight stream.\n *\n * When a turn is in flight: replays any pending approvals via\n * `approvals.listPending(sid)`, then forwards live events until the turn\n * ends. When no turn is in flight: 404.\n */\n app.get('/agents/:name/sessions/:id/events', async (c) => {\n if (!isAuthorized(token, c.req.header('authorization'))) {\n return c.json({ error: 'unauthorized' }, 401)\n }\n const name = c.req.param('name')\n const reject = checkAgent(c, name)\n if (reject) return reject\n const id = c.req.param('id')\n if (!UUID_RE.test(id)) {\n return c.json({ error: 'session id must be a UUID' }, 400)\n }\n if (!streams.has(id)) {\n return c.json({ error: 'no in-flight session' }, 404)\n }\n return streamSSE(c, async (sse) => {\n // Take ownership of dispatch for this session id.\n streams.set(id, sse)\n const keepalive = attachKeepalive(sse)\n // Replay any approvals that are still pending so the reconnecting\n // client can decide on them.\n for (const handle of approvals.listPending(id)) {\n await sse.writeSSE({ data: approvalRequestFrame(handle) })\n }\n try {\n while (streams.get(id) === sse) {\n await new Promise((r) => setTimeout(r, 250))\n }\n } finally {\n if (keepalive) clearInterval(keepalive)\n }\n })\n })\n\n /**\n * POST /agents/:name/sessions/:sid/approvals/:approval_id — resolve a\n * pending permission request.\n */\n app.post('/agents/:name/sessions/:sid/approvals/:approval_id', async (c) => {\n if (!isAuthorized(token, c.req.header('authorization'))) {\n return c.json({ error: 'unauthorized' }, 401)\n }\n const name = c.req.param('name')\n const reject = checkAgent(c, name)\n if (reject) return reject\n\n const sid = c.req.param('sid')\n const approvalId = c.req.param('approval_id')\n const parsed = await readJson(c)\n if (parsed && typeof parsed === 'object' && 'error' in parsed) {\n return c.json({ error: (parsed as { error: string }).error }, 400)\n }\n const body = parseDecisionBody(parsed)\n if ('error' in body) return c.json({ error: body.error }, 400)\n\n const decision: ApprovalDecision =\n body.decision === 'cancel'\n ? { decision: 'cancel' }\n : { decision: 'allow', optionId: body.option_id! }\n const ok = approvals.resolve(sid, approvalId, decision)\n if (!ok) return c.json({ error: 'unknown approval' }, 404)\n return c.json({ ok: true })\n })\n\n /**\n * POST /agents/:name/sessions/:sid/cancel — cancel every pending approval\n * for the session. Idempotent. Returns 204.\n */\n app.post('/agents/:name/sessions/:sid/cancel', async (c) => {\n if (!isAuthorized(token, c.req.header('authorization'))) {\n return c.json({ error: 'unauthorized' }, 401)\n }\n const name = c.req.param('name')\n const reject = checkAgent(c, name)\n if (reject) return reject\n const sid = c.req.param('sid')\n approvals.cancelSession(sid)\n return c.body(null, 204)\n })\n\n return app\n}\n","import type { TransportContextProvider } from '@zooid/core'\n\n/**\n * HTTP transport owns no durable conversation context in MVP. Returning null\n * tells the daemon to omit `zooid-context` from `session/new mcpServers` so\n * the shim never surfaces tools that would return nothing. See [ZOD046] for\n * the follow-on that would change this.\n */\nexport function getContextProvider(): TransportContextProvider | null {\n return null\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zooid/transport-http",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "HTTP transport for zooid. POST /sessions to start a session, POST /sessions/:id/turns to resume, GET /sessions/:id/events to tail. Bearer-auth, SSE responses.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.ts",
|
|
12
|
+
"import": "./src/index.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"src",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"hono": "^4.6.0",
|
|
28
|
+
"@zooid/acp-client": "^0.7.0",
|
|
29
|
+
"@zooid/core": "^0.7.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@hono/node-server": "^1.13.0",
|
|
33
|
+
"@types/node": "^22.0.0",
|
|
34
|
+
"tsup": "^8.5.1",
|
|
35
|
+
"tsx": "^4.21.0",
|
|
36
|
+
"typescript": "^5.5.0",
|
|
37
|
+
"vitest": "^3.2.0",
|
|
38
|
+
"@zooid/runtime-local": "^0.7.0"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsup",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"test:watch": "vitest",
|
|
44
|
+
"typecheck": "tsc --noEmit"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { getContextProvider } from './context.js'
|
|
3
|
+
|
|
4
|
+
describe('transport-http context surface', () => {
|
|
5
|
+
it('returns null — HTTP has no durable conversation context in MVP (see ZOD046)', () => {
|
|
6
|
+
expect(getContextProvider()).toBeNull()
|
|
7
|
+
})
|
|
8
|
+
})
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { TransportContextProvider } from '@zooid/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HTTP transport owns no durable conversation context in MVP. Returning null
|
|
5
|
+
* tells the daemon to omit `zooid-context` from `session/new mcpServers` so
|
|
6
|
+
* the shim never surfaces tools that would return nothing. See [ZOD046] for
|
|
7
|
+
* the follow-on that would change this.
|
|
8
|
+
*/
|
|
9
|
+
export function getContextProvider(): TransportContextProvider | null {
|
|
10
|
+
return null
|
|
11
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import type {
|
|
3
|
+
AcpRegistry,
|
|
4
|
+
AcpRegistryEventHandler,
|
|
5
|
+
AcpRegistryApprovalHandler,
|
|
6
|
+
} from '@zooid/core'
|
|
7
|
+
import { ApprovalCorrelator } from '@zooid/core'
|
|
8
|
+
import type {
|
|
9
|
+
AgentEvent,
|
|
10
|
+
ApprovalRequest,
|
|
11
|
+
PromptInput,
|
|
12
|
+
PromptResult,
|
|
13
|
+
} from '@zooid/acp-client'
|
|
14
|
+
import { createApp } from './server.js'
|
|
15
|
+
|
|
16
|
+
const TOKEN = 'test-token-0123456789abcdef'
|
|
17
|
+
const SID = '11111111-2222-3333-4444-555555555555'
|
|
18
|
+
|
|
19
|
+
interface FakeRegistryOptions {
|
|
20
|
+
agents?: string[]
|
|
21
|
+
events?: AgentEvent[]
|
|
22
|
+
stopReason?: PromptResult['stopReason']
|
|
23
|
+
sessionId?: string
|
|
24
|
+
ensureSession?: (name: string, threadId: string) => Promise<string>
|
|
25
|
+
prompt?: (name: string, input: PromptInput) => Promise<PromptResult>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeRegistry(opts: FakeRegistryOptions = {}): AcpRegistry {
|
|
29
|
+
const known = new Set(opts.agents ?? ['triage'])
|
|
30
|
+
const events = opts.events ?? []
|
|
31
|
+
let onEvent: AcpRegistryEventHandler = () => {}
|
|
32
|
+
let onApprovalRequest: AcpRegistryApprovalHandler = async () => ({ decision: 'cancel' })
|
|
33
|
+
|
|
34
|
+
const reg: AcpRegistry = {
|
|
35
|
+
hasAgent: (n) => known.has(n),
|
|
36
|
+
getApprovalTimeoutMs: () => 0,
|
|
37
|
+
ensureSession:
|
|
38
|
+
opts.ensureSession ??
|
|
39
|
+
vi.fn(async () => opts.sessionId ?? SID),
|
|
40
|
+
endSession: vi.fn(),
|
|
41
|
+
prompt:
|
|
42
|
+
opts.prompt ??
|
|
43
|
+
vi.fn(async (name) => {
|
|
44
|
+
for (const e of events) onEvent(name, e)
|
|
45
|
+
return { stopReason: opts.stopReason ?? 'end_turn' }
|
|
46
|
+
}),
|
|
47
|
+
stopAll: vi.fn(async () => {}),
|
|
48
|
+
get onEvent() {
|
|
49
|
+
return onEvent
|
|
50
|
+
},
|
|
51
|
+
set onEvent(h) {
|
|
52
|
+
onEvent = h
|
|
53
|
+
},
|
|
54
|
+
get onApprovalRequest() {
|
|
55
|
+
return onApprovalRequest
|
|
56
|
+
},
|
|
57
|
+
set onApprovalRequest(h) {
|
|
58
|
+
onApprovalRequest = h
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
return reg
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseFrames(text: string): unknown[] {
|
|
65
|
+
return text
|
|
66
|
+
.split('\n\n')
|
|
67
|
+
.filter((f) => f.startsWith('data: '))
|
|
68
|
+
.map((f) => JSON.parse(f.slice('data: '.length)))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function postJson(
|
|
72
|
+
app: ReturnType<typeof createApp>,
|
|
73
|
+
path: string,
|
|
74
|
+
body: object,
|
|
75
|
+
authOverride?: string | null,
|
|
76
|
+
) {
|
|
77
|
+
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
|
78
|
+
if (authOverride !== null) {
|
|
79
|
+
headers.authorization = authOverride ?? `Bearer ${TOKEN}`
|
|
80
|
+
}
|
|
81
|
+
return app.request(path, { method: 'POST', headers, body: JSON.stringify(body) })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function newApprovals(): ApprovalCorrelator {
|
|
85
|
+
return new ApprovalCorrelator()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── auth ────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
describe('auth', () => {
|
|
91
|
+
it('401 when Authorization header missing', async () => {
|
|
92
|
+
const app = createApp({ agents: makeRegistry(), approvals: newApprovals(), token: TOKEN })
|
|
93
|
+
const res = await postJson(app, '/agents/triage/sessions', { prompt: 'hi' }, null)
|
|
94
|
+
expect(res.status).toBe(401)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('401 when token is wrong', async () => {
|
|
98
|
+
const app = createApp({ agents: makeRegistry(), approvals: newApprovals(), token: TOKEN })
|
|
99
|
+
const res = await postJson(
|
|
100
|
+
app,
|
|
101
|
+
'/agents/triage/sessions',
|
|
102
|
+
{ prompt: 'hi' },
|
|
103
|
+
'Bearer wrong-token-of-different-length-xx',
|
|
104
|
+
)
|
|
105
|
+
expect(res.status).toBe(401)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('401 with non-Bearer scheme', async () => {
|
|
109
|
+
const app = createApp({ agents: makeRegistry(), approvals: newApprovals(), token: TOKEN })
|
|
110
|
+
const res = await postJson(
|
|
111
|
+
app,
|
|
112
|
+
'/agents/triage/sessions',
|
|
113
|
+
{ prompt: 'hi' },
|
|
114
|
+
`Basic ${TOKEN}`,
|
|
115
|
+
)
|
|
116
|
+
expect(res.status).toBe(401)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('401 with same-length wrong token (constant-time guard)', async () => {
|
|
120
|
+
const app = createApp({ agents: makeRegistry(), approvals: newApprovals(), token: TOKEN })
|
|
121
|
+
const wrong = 'X'.repeat(TOKEN.length)
|
|
122
|
+
const res = await postJson(
|
|
123
|
+
app,
|
|
124
|
+
'/agents/triage/sessions',
|
|
125
|
+
{ prompt: 'hi' },
|
|
126
|
+
`Bearer ${wrong}`,
|
|
127
|
+
)
|
|
128
|
+
expect(res.status).toBe(401)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// ─── POST /agents/:name/sessions ─────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
describe('POST /agents/:name/sessions', () => {
|
|
135
|
+
it('404 unknown agent', async () => {
|
|
136
|
+
const app = createApp({ agents: makeRegistry({ agents: ['triage'] }), approvals: newApprovals(), token: TOKEN })
|
|
137
|
+
const res = await postJson(app, '/agents/nobody/sessions', { prompt: 'hi' })
|
|
138
|
+
expect(res.status).toBe(404)
|
|
139
|
+
expect(await res.json()).toMatchObject({ error: expect.stringMatching(/unknown/i) })
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('400 missing prompt', async () => {
|
|
143
|
+
const app = createApp({ agents: makeRegistry(), approvals: newApprovals(), token: TOKEN })
|
|
144
|
+
const res = await postJson(app, '/agents/triage/sessions', {})
|
|
145
|
+
expect(res.status).toBe(400)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('400 non-JSON body', async () => {
|
|
149
|
+
const app = createApp({ agents: makeRegistry(), approvals: newApprovals(), token: TOKEN })
|
|
150
|
+
const res = await app.request('/agents/triage/sessions', {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${TOKEN}` },
|
|
153
|
+
body: 'not-json',
|
|
154
|
+
})
|
|
155
|
+
expect(res.status).toBe(400)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('happy path: SSE stream — session.start → turn.start → ACP events → turn.end', async () => {
|
|
159
|
+
const events: AgentEvent[] = [
|
|
160
|
+
{ type: 'agent_message_chunk', sessionId: SID, content: { type: 'text', text: 'hi' } },
|
|
161
|
+
{ type: 'plan', sessionId: SID, entries: [] },
|
|
162
|
+
]
|
|
163
|
+
const app = createApp({
|
|
164
|
+
agents: makeRegistry({ events, stopReason: 'end_turn' }),
|
|
165
|
+
approvals: newApprovals(),
|
|
166
|
+
token: TOKEN,
|
|
167
|
+
})
|
|
168
|
+
const res = await postJson(app, '/agents/triage/sessions', { prompt: 'hi' })
|
|
169
|
+
expect(res.status).toBe(200)
|
|
170
|
+
expect(res.headers.get('content-type')).toContain('text/event-stream')
|
|
171
|
+
const frames = parseFrames(await res.text()) as Array<Record<string, unknown>>
|
|
172
|
+
expect(frames[0]).toEqual({ type: 'session.start', session_id: SID })
|
|
173
|
+
expect(frames[1]).toEqual({ type: 'turn.start' })
|
|
174
|
+
expect(frames.slice(2, -1).map((f) => f.type)).toEqual(['agent_message_chunk', 'plan'])
|
|
175
|
+
const last = frames[frames.length - 1]
|
|
176
|
+
expect(last.type).toBe('turn.end')
|
|
177
|
+
expect(last.stop_reason).toBe('end_turn')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('calls registry.prompt(name, { threadId, content }) with a server-generated threadId', async () => {
|
|
181
|
+
const promptMock = vi.fn(async () => ({ stopReason: 'end_turn' as const }))
|
|
182
|
+
const app = createApp({
|
|
183
|
+
agents: makeRegistry({ prompt: promptMock }),
|
|
184
|
+
approvals: newApprovals(),
|
|
185
|
+
token: TOKEN,
|
|
186
|
+
})
|
|
187
|
+
const res = await postJson(app, '/agents/triage/sessions', { prompt: 'hello' })
|
|
188
|
+
await res.text()
|
|
189
|
+
expect(promptMock).toHaveBeenCalledTimes(1)
|
|
190
|
+
const [name, input] = promptMock.mock.calls[0]
|
|
191
|
+
expect(name).toBe('triage')
|
|
192
|
+
expect(input.threadId).toMatch(/^[0-9a-f-]{8,}$/)
|
|
193
|
+
expect(Array.isArray(input.content)).toBe(true)
|
|
194
|
+
expect(input.content[0]).toMatchObject({ type: 'text', text: 'hello' })
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
// ─── GET /agents/:name/sessions/:id/events ───────────────────────────────
|
|
199
|
+
|
|
200
|
+
describe('GET /agents/:name/sessions/:id/events', () => {
|
|
201
|
+
it('404 when no turn is currently in flight for that session id', async () => {
|
|
202
|
+
const app = createApp({ agents: makeRegistry(), approvals: newApprovals(), token: TOKEN })
|
|
203
|
+
const res = await app.request(`/agents/triage/sessions/${SID}/events`, {
|
|
204
|
+
headers: { authorization: `Bearer ${TOKEN}` },
|
|
205
|
+
})
|
|
206
|
+
expect(res.status).toBe(404)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('400 on non-UUID id', async () => {
|
|
210
|
+
const app = createApp({ agents: makeRegistry(), approvals: newApprovals(), token: TOKEN })
|
|
211
|
+
const res = await app.request('/agents/triage/sessions/not-a-uuid/events', {
|
|
212
|
+
headers: { authorization: `Bearer ${TOKEN}` },
|
|
213
|
+
})
|
|
214
|
+
expect(res.status).toBe(400)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('404 unknown agent', async () => {
|
|
218
|
+
const app = createApp({ agents: makeRegistry({ agents: ['triage'] }), approvals: newApprovals(), token: TOKEN })
|
|
219
|
+
const res = await app.request(`/agents/ghost/sessions/${SID}/events`, {
|
|
220
|
+
headers: { authorization: `Bearer ${TOKEN}` },
|
|
221
|
+
})
|
|
222
|
+
expect(res.status).toBe(404)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('401 without auth', async () => {
|
|
226
|
+
const app = createApp({ agents: makeRegistry(), approvals: newApprovals(), token: TOKEN })
|
|
227
|
+
const res = await app.request(`/agents/triage/sessions/${SID}/events`)
|
|
228
|
+
expect(res.status).toBe(401)
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
// ─── legacy routes ──────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
describe('legacy /sessions and /run routes are gone', () => {
|
|
235
|
+
it('POST /run → 404', async () => {
|
|
236
|
+
const app = createApp({ agents: makeRegistry(), approvals: newApprovals(), token: TOKEN })
|
|
237
|
+
const res = await postJson(app, '/run', { prompt: 'hi' })
|
|
238
|
+
expect(res.status).toBe(404)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('POST /sessions → 404', async () => {
|
|
242
|
+
const app = createApp({ agents: makeRegistry(), approvals: newApprovals(), token: TOKEN })
|
|
243
|
+
const res = await postJson(app, '/sessions', { prompt: 'hi' })
|
|
244
|
+
expect(res.status).toBe(404)
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
// ─── approval round-trip (Plan-02) ───────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
const APPROVAL_ID_RE = /^[0-9a-f-]{30,}$/
|
|
251
|
+
|
|
252
|
+
function approvalRequest(toolCallId: string): ApprovalRequest {
|
|
253
|
+
return {
|
|
254
|
+
sessionId: SID,
|
|
255
|
+
toolCallId,
|
|
256
|
+
options: [
|
|
257
|
+
{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
|
|
258
|
+
{ optionId: 'reject-once', name: 'Reject once', kind: 'reject_once' },
|
|
259
|
+
],
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
describe('SSE approval.request emission', () => {
|
|
264
|
+
it('emits approval.request when the agent requests permission', async () => {
|
|
265
|
+
const approvals = newApprovals()
|
|
266
|
+
const reg: AcpRegistry = makeRegistry({
|
|
267
|
+
prompt: async (name) => {
|
|
268
|
+
// The transport replaces registry.onApprovalRequest with one that
|
|
269
|
+
// registers on the correlator + emits approval.request on the SSE.
|
|
270
|
+
// The AcpClient would call it — we do so directly to simulate.
|
|
271
|
+
const decisionPromise = reg.onApprovalRequest(name, approvalRequest('tc-1'))
|
|
272
|
+
// Wait until the approval is registered, then resolve it.
|
|
273
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
274
|
+
const pending = approvals.listPending(SID)
|
|
275
|
+
expect(pending.length).toBeGreaterThan(0)
|
|
276
|
+
approvals.resolve(SID, pending[0].approvalId, {
|
|
277
|
+
decision: 'allow',
|
|
278
|
+
optionId: 'allow-once',
|
|
279
|
+
})
|
|
280
|
+
const decision = await decisionPromise
|
|
281
|
+
return { stopReason: decision.decision === 'allow' ? 'end_turn' : 'refusal' }
|
|
282
|
+
},
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
const app = createApp({ agents: reg, approvals, token: TOKEN })
|
|
286
|
+
const res = await postJson(app, '/agents/triage/sessions', { prompt: 'hi' })
|
|
287
|
+
expect(res.status).toBe(200)
|
|
288
|
+
const frames = parseFrames(await res.text()) as Array<Record<string, unknown>>
|
|
289
|
+
const approvalFrame = frames.find((f) => f.type === 'approval.request')
|
|
290
|
+
expect(approvalFrame).toBeDefined()
|
|
291
|
+
expect((approvalFrame as { approval_id: string }).approval_id).toMatch(APPROVAL_ID_RE)
|
|
292
|
+
const last = frames[frames.length - 1]
|
|
293
|
+
expect(last.type).toBe('turn.end')
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
describe('POST /agents/:name/sessions/:sid/approvals/:approval_id', () => {
|
|
298
|
+
it('401 without bearer token', async () => {
|
|
299
|
+
const app = createApp({ agents: makeRegistry(), approvals: newApprovals(), token: TOKEN })
|
|
300
|
+
const res = await postJson(
|
|
301
|
+
app,
|
|
302
|
+
`/agents/triage/sessions/${SID}/approvals/abc`,
|
|
303
|
+
{ decision: 'cancel' },
|
|
304
|
+
null,
|
|
305
|
+
)
|
|
306
|
+
expect(res.status).toBe(401)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('404 for unknown agent', async () => {
|
|
310
|
+
const app = createApp({
|
|
311
|
+
agents: makeRegistry({ agents: ['triage'] }),
|
|
312
|
+
approvals: newApprovals(),
|
|
313
|
+
token: TOKEN,
|
|
314
|
+
})
|
|
315
|
+
const res = await postJson(
|
|
316
|
+
app,
|
|
317
|
+
`/agents/ghost/sessions/${SID}/approvals/abc`,
|
|
318
|
+
{ decision: 'cancel' },
|
|
319
|
+
)
|
|
320
|
+
expect(res.status).toBe(404)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('404 for unknown approval id (correlator returns false)', async () => {
|
|
324
|
+
const app = createApp({ agents: makeRegistry(), approvals: newApprovals(), token: TOKEN })
|
|
325
|
+
const res = await postJson(
|
|
326
|
+
app,
|
|
327
|
+
`/agents/triage/sessions/${SID}/approvals/nope`,
|
|
328
|
+
{ decision: 'cancel' },
|
|
329
|
+
)
|
|
330
|
+
expect(res.status).toBe(404)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('400 for body missing decision', async () => {
|
|
334
|
+
const approvals = newApprovals()
|
|
335
|
+
const app = createApp({ agents: makeRegistry(), approvals, token: TOKEN })
|
|
336
|
+
const res = await postJson(
|
|
337
|
+
app,
|
|
338
|
+
`/agents/triage/sessions/${SID}/approvals/anything`,
|
|
339
|
+
{},
|
|
340
|
+
)
|
|
341
|
+
expect(res.status).toBe(400)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('400 when allow lacks option_id', async () => {
|
|
345
|
+
const approvals = newApprovals()
|
|
346
|
+
const handle = approvals.register('triage', SID, approvalRequest('tc-1'))
|
|
347
|
+
const app = createApp({ agents: makeRegistry(), approvals, token: TOKEN })
|
|
348
|
+
const res = await postJson(
|
|
349
|
+
app,
|
|
350
|
+
`/agents/triage/sessions/${SID}/approvals/${handle.approvalId}`,
|
|
351
|
+
{ decision: 'allow' },
|
|
352
|
+
)
|
|
353
|
+
expect(res.status).toBe(400)
|
|
354
|
+
void handle.decisionPromise.catch(() => {})
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('200 + resolves the in-flight Promise on valid allow', async () => {
|
|
358
|
+
const approvals = newApprovals()
|
|
359
|
+
const handle = approvals.register('triage', SID, approvalRequest('tc-1'))
|
|
360
|
+
const app = createApp({ agents: makeRegistry(), approvals, token: TOKEN })
|
|
361
|
+
const res = await postJson(
|
|
362
|
+
app,
|
|
363
|
+
`/agents/triage/sessions/${SID}/approvals/${handle.approvalId}`,
|
|
364
|
+
{ decision: 'allow', option_id: 'allow-once' },
|
|
365
|
+
)
|
|
366
|
+
expect(res.status).toBe(200)
|
|
367
|
+
await expect(handle.decisionPromise).resolves.toEqual({
|
|
368
|
+
decision: 'allow',
|
|
369
|
+
optionId: 'allow-once',
|
|
370
|
+
})
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('200 + resolves with cancel on { decision: "cancel" }', async () => {
|
|
374
|
+
const approvals = newApprovals()
|
|
375
|
+
const handle = approvals.register('triage', SID, approvalRequest('tc-1'))
|
|
376
|
+
const app = createApp({ agents: makeRegistry(), approvals, token: TOKEN })
|
|
377
|
+
const res = await postJson(
|
|
378
|
+
app,
|
|
379
|
+
`/agents/triage/sessions/${SID}/approvals/${handle.approvalId}`,
|
|
380
|
+
{ decision: 'cancel' },
|
|
381
|
+
)
|
|
382
|
+
expect(res.status).toBe(200)
|
|
383
|
+
await expect(handle.decisionPromise).resolves.toEqual({ decision: 'cancel' })
|
|
384
|
+
})
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
describe('POST /agents/:name/sessions/:sid/cancel', () => {
|
|
388
|
+
it('401 without bearer token', async () => {
|
|
389
|
+
const app = createApp({ agents: makeRegistry(), approvals: newApprovals(), token: TOKEN })
|
|
390
|
+
const res = await postJson(app, `/agents/triage/sessions/${SID}/cancel`, {}, null)
|
|
391
|
+
expect(res.status).toBe(401)
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('404 for unknown agent', async () => {
|
|
395
|
+
const app = createApp({
|
|
396
|
+
agents: makeRegistry({ agents: ['triage'] }),
|
|
397
|
+
approvals: newApprovals(),
|
|
398
|
+
token: TOKEN,
|
|
399
|
+
})
|
|
400
|
+
const res = await postJson(app, `/agents/ghost/sessions/${SID}/cancel`, {})
|
|
401
|
+
expect(res.status).toBe(404)
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('204 and cancels every pending approval for the session', async () => {
|
|
405
|
+
const approvals = newApprovals()
|
|
406
|
+
const a = approvals.register('triage', SID, approvalRequest('tc-1'))
|
|
407
|
+
const b = approvals.register('triage', SID, approvalRequest('tc-2'))
|
|
408
|
+
const app = createApp({ agents: makeRegistry(), approvals, token: TOKEN })
|
|
409
|
+
const res = await postJson(app, `/agents/triage/sessions/${SID}/cancel`, {})
|
|
410
|
+
expect(res.status).toBe(204)
|
|
411
|
+
await expect(a.decisionPromise).resolves.toEqual({ decision: 'cancel' })
|
|
412
|
+
await expect(b.decisionPromise).resolves.toEqual({ decision: 'cancel' })
|
|
413
|
+
expect(approvals.size()).toBe(0)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('204 even with no pending approvals (idempotent)', async () => {
|
|
417
|
+
const app = createApp({ agents: makeRegistry(), approvals: newApprovals(), token: TOKEN })
|
|
418
|
+
const res = await postJson(app, `/agents/triage/sessions/${SID}/cancel`, {})
|
|
419
|
+
expect(res.status).toBe(204)
|
|
420
|
+
})
|
|
421
|
+
})
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { Hono, type Context } from 'hono'
|
|
2
|
+
import { streamSSE, type SSEStreamingApi } from 'hono/streaming'
|
|
3
|
+
import { timingSafeEqual, randomUUID } from 'node:crypto'
|
|
4
|
+
import type {
|
|
5
|
+
AcpRegistry,
|
|
6
|
+
ApprovalCorrelator,
|
|
7
|
+
RegisteredApproval,
|
|
8
|
+
} from '@zooid/core'
|
|
9
|
+
import type {
|
|
10
|
+
AgentEvent,
|
|
11
|
+
ApprovalDecision,
|
|
12
|
+
} from '@zooid/acp-client'
|
|
13
|
+
|
|
14
|
+
export interface CreateAppOptions {
|
|
15
|
+
/** Long-lived registry that fronts every ACP agent. */
|
|
16
|
+
agents: AcpRegistry
|
|
17
|
+
/**
|
|
18
|
+
* Correlator that pairs ACP `requestPermission` calls with HTTP-side
|
|
19
|
+
* decisions. The transport listens to its `'registered'` and `'timeout'`
|
|
20
|
+
* events to drive the SSE wire and resolves it via the POST decision route.
|
|
21
|
+
*/
|
|
22
|
+
approvals: ApprovalCorrelator
|
|
23
|
+
/** Bearer token; constant-time compared against `Authorization`. */
|
|
24
|
+
token: string
|
|
25
|
+
/**
|
|
26
|
+
* Keepalive interval for open SSE streams (proxies kill idle connections).
|
|
27
|
+
* 0 disables. Default: 30_000ms.
|
|
28
|
+
*/
|
|
29
|
+
keepaliveMs?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
33
|
+
|
|
34
|
+
interface PromptBody {
|
|
35
|
+
prompt: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isAuthorized(token: string, header: string | undefined): boolean {
|
|
39
|
+
if (!header || !header.startsWith('Bearer ')) return false
|
|
40
|
+
const provided = header.slice('Bearer '.length)
|
|
41
|
+
if (provided.length !== token.length) return false
|
|
42
|
+
return timingSafeEqual(Buffer.from(provided), Buffer.from(token))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parsePromptBody(raw: unknown): PromptBody | { error: string } {
|
|
46
|
+
if (!raw || typeof raw !== 'object') return { error: 'body must be a JSON object' }
|
|
47
|
+
const obj = raw as Record<string, unknown>
|
|
48
|
+
if (typeof obj.prompt !== 'string' || obj.prompt.length === 0) {
|
|
49
|
+
return { error: 'missing or empty prompt' }
|
|
50
|
+
}
|
|
51
|
+
return { prompt: obj.prompt }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function readJson(c: Context): Promise<unknown | { error: string }> {
|
|
55
|
+
const raw = await c.req.text()
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(raw)
|
|
58
|
+
} catch {
|
|
59
|
+
return { error: 'body must be valid JSON' }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface DecisionBody {
|
|
64
|
+
decision: 'allow' | 'cancel'
|
|
65
|
+
option_id?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseDecisionBody(raw: unknown): DecisionBody | { error: string } {
|
|
69
|
+
if (!raw || typeof raw !== 'object') return { error: 'body must be a JSON object' }
|
|
70
|
+
const obj = raw as Record<string, unknown>
|
|
71
|
+
if (obj.decision !== 'allow' && obj.decision !== 'cancel') {
|
|
72
|
+
return { error: 'decision must be "allow" or "cancel"' }
|
|
73
|
+
}
|
|
74
|
+
if (obj.decision === 'allow' && typeof obj.option_id !== 'string') {
|
|
75
|
+
return { error: 'option_id is required when decision is "allow"' }
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
decision: obj.decision,
|
|
79
|
+
option_id: typeof obj.option_id === 'string' ? obj.option_id : undefined,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function approvalRequestFrame(handle: RegisteredApproval): string {
|
|
84
|
+
return JSON.stringify({
|
|
85
|
+
type: 'approval.request',
|
|
86
|
+
approval_id: handle.approvalId,
|
|
87
|
+
session_id: handle.sessionId,
|
|
88
|
+
tool_call_id: handle.toolCallId,
|
|
89
|
+
options: handle.options,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createApp({
|
|
94
|
+
agents,
|
|
95
|
+
approvals,
|
|
96
|
+
token,
|
|
97
|
+
keepaliveMs = 30_000,
|
|
98
|
+
}: CreateAppOptions) {
|
|
99
|
+
const app = new Hono()
|
|
100
|
+
|
|
101
|
+
// Per-session SSE handle. The registry's onEvent dispatcher and the
|
|
102
|
+
// correlator's `'registered'` / `'timeout'` listeners look up the
|
|
103
|
+
// currently-attached stream by session id.
|
|
104
|
+
const streams = new Map<string, SSEStreamingApi>()
|
|
105
|
+
|
|
106
|
+
agents.onEvent = (_name, event: AgentEvent) => {
|
|
107
|
+
const stream = streams.get(event.sessionId)
|
|
108
|
+
if (!stream) return
|
|
109
|
+
void stream.writeSSE({ data: JSON.stringify(event) })
|
|
110
|
+
}
|
|
111
|
+
// Always wire the registry's approval handler to the correlator. The
|
|
112
|
+
// per-agent `approval_timeout_ms` is read off the registry so YAML-driven
|
|
113
|
+
// timeouts still apply.
|
|
114
|
+
agents.onApprovalRequest = async (name, req) => {
|
|
115
|
+
const handle = approvals.register(name, req.sessionId, req, {
|
|
116
|
+
timeoutMs: agents.getApprovalTimeoutMs(name),
|
|
117
|
+
})
|
|
118
|
+
return handle.decisionPromise
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
approvals.on('registered', (handle: RegisteredApproval) => {
|
|
122
|
+
const stream = streams.get(handle.sessionId)
|
|
123
|
+
if (!stream) return
|
|
124
|
+
void stream.writeSSE({ data: approvalRequestFrame(handle) })
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
approvals.on('timeout', ({ approvalId, sessionId }: { approvalId: string; sessionId: string }) => {
|
|
128
|
+
const stream = streams.get(sessionId)
|
|
129
|
+
if (!stream) return
|
|
130
|
+
void stream.writeSSE({
|
|
131
|
+
data: JSON.stringify({ type: 'approval.timeout', approval_id: approvalId, session_id: sessionId }),
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
function checkAgent(c: Context, name: string): Response | null {
|
|
136
|
+
if (!agents.hasAgent(name)) {
|
|
137
|
+
return c.json({ error: 'unknown agent' }, 404)
|
|
138
|
+
}
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function attachKeepalive(sse: SSEStreamingApi): NodeJS.Timeout | null {
|
|
143
|
+
if (!keepaliveMs || keepaliveMs <= 0) return null
|
|
144
|
+
const timer = setInterval(() => {
|
|
145
|
+
void sse.writeSSE({ data: '', event: 'keepalive' }).catch(() => {})
|
|
146
|
+
}, keepaliveMs)
|
|
147
|
+
timer.unref?.()
|
|
148
|
+
return timer
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* POST /agents/:name/sessions — start a new ACP session.
|
|
153
|
+
*
|
|
154
|
+
* { session.start, session_id }
|
|
155
|
+
* { turn.start }
|
|
156
|
+
* ... ACP AgentEvents
|
|
157
|
+
* { approval.request, ... } / { approval.timeout, ... } as they arrive
|
|
158
|
+
* { turn.end, stop_reason }
|
|
159
|
+
*/
|
|
160
|
+
app.post('/agents/:name/sessions', async (c) => {
|
|
161
|
+
if (!isAuthorized(token, c.req.header('authorization'))) {
|
|
162
|
+
return c.json({ error: 'unauthorized' }, 401)
|
|
163
|
+
}
|
|
164
|
+
const name = c.req.param('name')
|
|
165
|
+
const reject = checkAgent(c, name)
|
|
166
|
+
if (reject) return reject
|
|
167
|
+
|
|
168
|
+
const parsed = await readJson(c)
|
|
169
|
+
if (parsed && typeof parsed === 'object' && 'error' in parsed) {
|
|
170
|
+
return c.json({ error: (parsed as { error: string }).error }, 400)
|
|
171
|
+
}
|
|
172
|
+
const body = parsePromptBody(parsed)
|
|
173
|
+
if ('error' in body) return c.json({ error: body.error }, 400)
|
|
174
|
+
|
|
175
|
+
const threadId = randomUUID()
|
|
176
|
+
return streamSSE(c, async (sse) => {
|
|
177
|
+
let sessionId: string | null = null
|
|
178
|
+
let keepalive: NodeJS.Timeout | null = null
|
|
179
|
+
try {
|
|
180
|
+
sessionId = await agents.ensureSession(name, threadId)
|
|
181
|
+
streams.set(sessionId, sse)
|
|
182
|
+
keepalive = attachKeepalive(sse)
|
|
183
|
+
await sse.writeSSE({
|
|
184
|
+
data: JSON.stringify({ type: 'session.start', session_id: sessionId }),
|
|
185
|
+
})
|
|
186
|
+
await sse.writeSSE({ data: JSON.stringify({ type: 'turn.start' }) })
|
|
187
|
+
const result = await agents.prompt(name, {
|
|
188
|
+
threadId,
|
|
189
|
+
content: [{ type: 'text', text: body.prompt }],
|
|
190
|
+
})
|
|
191
|
+
await sse.writeSSE({
|
|
192
|
+
data: JSON.stringify({ type: 'turn.end', stop_reason: result.stopReason }),
|
|
193
|
+
})
|
|
194
|
+
} catch (err) {
|
|
195
|
+
await sse.writeSSE({
|
|
196
|
+
data: JSON.stringify({
|
|
197
|
+
type: 'turn.end',
|
|
198
|
+
stop_reason: 'error',
|
|
199
|
+
error: (err as Error).message,
|
|
200
|
+
}),
|
|
201
|
+
})
|
|
202
|
+
} finally {
|
|
203
|
+
if (keepalive) clearInterval(keepalive)
|
|
204
|
+
if (sessionId) {
|
|
205
|
+
// The turn is over; cancel any approvals that the shim left
|
|
206
|
+
// dangling so future POSTs against them 404 fast.
|
|
207
|
+
approvals.cancelSession(sessionId)
|
|
208
|
+
if (streams.get(sessionId) === sse) {
|
|
209
|
+
streams.delete(sessionId)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* GET /agents/:name/sessions/:id/events — reattach to an in-flight stream.
|
|
218
|
+
*
|
|
219
|
+
* When a turn is in flight: replays any pending approvals via
|
|
220
|
+
* `approvals.listPending(sid)`, then forwards live events until the turn
|
|
221
|
+
* ends. When no turn is in flight: 404.
|
|
222
|
+
*/
|
|
223
|
+
app.get('/agents/:name/sessions/:id/events', async (c) => {
|
|
224
|
+
if (!isAuthorized(token, c.req.header('authorization'))) {
|
|
225
|
+
return c.json({ error: 'unauthorized' }, 401)
|
|
226
|
+
}
|
|
227
|
+
const name = c.req.param('name')
|
|
228
|
+
const reject = checkAgent(c, name)
|
|
229
|
+
if (reject) return reject
|
|
230
|
+
const id = c.req.param('id')
|
|
231
|
+
if (!UUID_RE.test(id)) {
|
|
232
|
+
return c.json({ error: 'session id must be a UUID' }, 400)
|
|
233
|
+
}
|
|
234
|
+
if (!streams.has(id)) {
|
|
235
|
+
return c.json({ error: 'no in-flight session' }, 404)
|
|
236
|
+
}
|
|
237
|
+
return streamSSE(c, async (sse) => {
|
|
238
|
+
// Take ownership of dispatch for this session id.
|
|
239
|
+
streams.set(id, sse)
|
|
240
|
+
const keepalive = attachKeepalive(sse)
|
|
241
|
+
// Replay any approvals that are still pending so the reconnecting
|
|
242
|
+
// client can decide on them.
|
|
243
|
+
for (const handle of approvals.listPending(id)) {
|
|
244
|
+
await sse.writeSSE({ data: approvalRequestFrame(handle) })
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
while (streams.get(id) === sse) {
|
|
248
|
+
await new Promise((r) => setTimeout(r, 250))
|
|
249
|
+
}
|
|
250
|
+
} finally {
|
|
251
|
+
if (keepalive) clearInterval(keepalive)
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* POST /agents/:name/sessions/:sid/approvals/:approval_id — resolve a
|
|
258
|
+
* pending permission request.
|
|
259
|
+
*/
|
|
260
|
+
app.post('/agents/:name/sessions/:sid/approvals/:approval_id', async (c) => {
|
|
261
|
+
if (!isAuthorized(token, c.req.header('authorization'))) {
|
|
262
|
+
return c.json({ error: 'unauthorized' }, 401)
|
|
263
|
+
}
|
|
264
|
+
const name = c.req.param('name')
|
|
265
|
+
const reject = checkAgent(c, name)
|
|
266
|
+
if (reject) return reject
|
|
267
|
+
|
|
268
|
+
const sid = c.req.param('sid')
|
|
269
|
+
const approvalId = c.req.param('approval_id')
|
|
270
|
+
const parsed = await readJson(c)
|
|
271
|
+
if (parsed && typeof parsed === 'object' && 'error' in parsed) {
|
|
272
|
+
return c.json({ error: (parsed as { error: string }).error }, 400)
|
|
273
|
+
}
|
|
274
|
+
const body = parseDecisionBody(parsed)
|
|
275
|
+
if ('error' in body) return c.json({ error: body.error }, 400)
|
|
276
|
+
|
|
277
|
+
const decision: ApprovalDecision =
|
|
278
|
+
body.decision === 'cancel'
|
|
279
|
+
? { decision: 'cancel' }
|
|
280
|
+
: { decision: 'allow', optionId: body.option_id! }
|
|
281
|
+
const ok = approvals.resolve(sid, approvalId, decision)
|
|
282
|
+
if (!ok) return c.json({ error: 'unknown approval' }, 404)
|
|
283
|
+
return c.json({ ok: true })
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* POST /agents/:name/sessions/:sid/cancel — cancel every pending approval
|
|
288
|
+
* for the session. Idempotent. Returns 204.
|
|
289
|
+
*/
|
|
290
|
+
app.post('/agents/:name/sessions/:sid/cancel', async (c) => {
|
|
291
|
+
if (!isAuthorized(token, c.req.header('authorization'))) {
|
|
292
|
+
return c.json({ error: 'unauthorized' }, 401)
|
|
293
|
+
}
|
|
294
|
+
const name = c.req.param('name')
|
|
295
|
+
const reject = checkAgent(c, name)
|
|
296
|
+
if (reject) return reject
|
|
297
|
+
const sid = c.req.param('sid')
|
|
298
|
+
approvals.cancelSession(sid)
|
|
299
|
+
return c.body(null, 204)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
return app
|
|
303
|
+
}
|