a2a-exposed 0.8.10 → 0.8.12
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/lib/commands.mjs +23 -8
- package/lib/main.mjs +2 -0
- package/package.json +3 -2
- package/worker/deploy.env.example +1 -1
- package/worker/package.json +4 -3
- package/worker/src/index.ts +19 -3
- package/worker/src/owner-rpc.ts +67 -2
- package/worker/src/version.ts +1 -1
- package/worker/test/mcp.test.ts +37 -0
- package/worker/test/owner-mcp.test.ts +125 -0
- package/worker/test/owner-rpc.test.ts +43 -2
- package/worker/tsconfig.json +1 -1
package/lib/commands.mjs
CHANGED
|
@@ -253,13 +253,24 @@ export function peers(sub, args, o) {
|
|
|
253
253
|
}
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
const ownerConfigured = () => !!(baseUrl() && C.get("A2A_OWNER_TOKEN"));
|
|
257
|
+
|
|
258
|
+
// Outbound delivery works with just a peer token unless --push is requested.
|
|
259
|
+
// Persistence failures must not hide successful delivery (and invite a duplicate send).
|
|
260
|
+
async function persistOutbound(method, path, body, action) {
|
|
261
|
+
if (!ownerConfigured()) return false;
|
|
262
|
+
try { await owner(method, path, body); return true; }
|
|
263
|
+
catch { console.error(`warning: could not ${action} to the worker`); return false; }
|
|
264
|
+
}
|
|
265
|
+
|
|
256
266
|
async function logHistory(ctx, entry) {
|
|
257
|
-
|
|
258
|
-
catch { console.error("warning: could not log history to the worker"); }
|
|
267
|
+
await persistOutbound("POST", "/owner/history", { contextId: ctx, ...entry }, "log history");
|
|
259
268
|
}
|
|
260
269
|
|
|
261
270
|
export async function send(o) {
|
|
262
271
|
if (!o.to) die("--to required");
|
|
272
|
+
// The callback authenticates against the token saved through the owner API.
|
|
273
|
+
if (o.push && !ownerConfigured()) die("--push needs A2A_BASE_URL and A2A_OWNER_TOKEN (your Worker); configure both or omit --push and poll instead");
|
|
263
274
|
const [alias, base, tok] = resolvePeer(o.to);
|
|
264
275
|
const card = await fetchCard(base);
|
|
265
276
|
const [url, version] = pickEndpoint(base, card, o.proto);
|
|
@@ -272,7 +283,6 @@ export async function send(o) {
|
|
|
272
283
|
const conf = v1 ? { returnImmediately: true } : { blocking: false };
|
|
273
284
|
let pushToken = null;
|
|
274
285
|
if (o.push) {
|
|
275
|
-
if (!baseUrl()) die("--push needs A2A_BASE_URL (your Worker)");
|
|
276
286
|
pushToken = randomToken(24);
|
|
277
287
|
conf[v1 ? "taskPushNotificationConfig" : "pushNotificationConfig"] = { url: baseUrl() + "/push", token: pushToken };
|
|
278
288
|
}
|
|
@@ -282,10 +292,13 @@ export async function send(o) {
|
|
|
282
292
|
const isTask = obj && typeof obj === "object" && "status" in obj;
|
|
283
293
|
const ctx = (obj && obj.contextId) || o.context || newId();
|
|
284
294
|
const tid = isTask ? obj.id : null;
|
|
295
|
+
out(obj);
|
|
285
296
|
await logHistory(ctx, { dir: "out", peer: alias, taskId: tid, role: "user", text, data: { messageId: msg.messageId, endpoint: url, protocol: version } });
|
|
286
|
-
if (tid)
|
|
297
|
+
if (tid) {
|
|
298
|
+
const saved = await persistOutbound("POST", "/owner/outbound", { taskId: tid, contextId: ctx, peer: alias, endpoint: url, protocol: version, pushToken, task: obj }, "save outbound task");
|
|
299
|
+
if (o.push && !saved) console.error(`warning: push updates cannot authenticate because the outbound task token could not be saved; use ${CLI} poll --to ${alias} ${tid} instead; the message was delivered, do not resend`);
|
|
300
|
+
}
|
|
287
301
|
else if (obj && obj.parts) await logHistory(ctx, { dir: "in", peer: alias, role: "agent", event: "direct_message", text: textOf(obj) });
|
|
288
|
-
out(obj);
|
|
289
302
|
if (tid) console.error(`# task ${tid}: ${plainState(obj)} (check: ${CLI} poll --to ${alias} ${tid})`);
|
|
290
303
|
}
|
|
291
304
|
|
|
@@ -294,17 +307,19 @@ export async function poll(taskId, o) {
|
|
|
294
307
|
const [alias, base, tok] = resolvePeer(o.to);
|
|
295
308
|
const tid = checkId(taskId, "taskId");
|
|
296
309
|
let rec = {};
|
|
297
|
-
|
|
310
|
+
if (ownerConfigured()) {
|
|
311
|
+
try { rec = await owner("GET", `/owner/outbound/${q(tid)}`); } catch { rec = {}; }
|
|
312
|
+
}
|
|
298
313
|
const [url, version] = rec.endpoint ? [rec.endpoint, rec.protocol || "0.3"] : pickEndpoint(base, await fetchCard(base), o.proto);
|
|
299
314
|
const res = await rpc(url, version, tok, "tasks/get", "GetTask", { id: tid }, { alias: C.loadPeers()[alias] ? alias : null, base });
|
|
315
|
+
out(res);
|
|
300
316
|
if (rec.endpoint) {
|
|
301
|
-
await
|
|
317
|
+
await persistOutbound("PUT", `/owner/outbound/${q(tid)}`, { task: res }, "update outbound task");
|
|
302
318
|
let txt = textOf((res.status || {}).message || {});
|
|
303
319
|
const arts = (res.artifacts || []).map((x) => textOf(x)).join("\n");
|
|
304
320
|
if (arts && arts !== txt) txt = (txt ? txt + "\n" : "") + arts;
|
|
305
321
|
await logHistory(rec.contextId, { dir: "in", peer: alias, taskId: tid, event: "poll", state: plainState(res), text: txt });
|
|
306
322
|
}
|
|
307
|
-
out(res);
|
|
308
323
|
console.error(`# task ${tid}: ${plainState(res)}`);
|
|
309
324
|
}
|
|
310
325
|
|
package/lib/main.mjs
CHANGED
|
@@ -228,6 +228,8 @@ Outbound (agents you call)
|
|
|
228
228
|
differs between the two, connect/send/poll/peers/status warn (values never shown)
|
|
229
229
|
send --to <alias|url> [--text T | stdin] [--context C] [--task T] [--push] [--proto 0.3|1.0]
|
|
230
230
|
poll --to <alias|url> <taskId> [--proto 0.3|1.0]
|
|
231
|
+
send/poll need only peer config; owner history/task storage is optional.
|
|
232
|
+
Storage failures warn on stderr; the peer response still prints and exits 0
|
|
231
233
|
outbound <taskId> stored state of a task you sent (incl. pushed updates)
|
|
232
234
|
|
|
233
235
|
Owner
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "a2a-exposed",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.12",
|
|
4
4
|
"description": "Give any AI agent a public A2A (Agent2Agent) endpoint: a Cloudflare Worker, a wake webhook, or scheduled checks",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
17
|
"prepack": "node scripts/sync-worker.mjs && node -e \"require('fs').copyFileSync('../LICENSE','LICENSE')\"",
|
|
18
|
-
"test": "node --test test/*.test.mjs"
|
|
18
|
+
"test": "node --test test/*.test.mjs",
|
|
19
|
+
"test:coverage": "node --experimental-test-coverage --test-coverage-include=lib/** --test-reporter=spec --test-reporter=lcov --test-reporter-destination=stdout --test-reporter-destination=../coverage/cli.lcov --test test/*.test.mjs"
|
|
19
20
|
},
|
|
20
21
|
"engines": {
|
|
21
22
|
"node": ">=22.18"
|
|
@@ -16,7 +16,7 @@ A2A_AGENT_DESCRIPTION=An AI agent reachable over A2A. Replies asynchronously.
|
|
|
16
16
|
WAKE_PRESET=generic
|
|
17
17
|
# WAKE_AGENT_ID=main
|
|
18
18
|
# WAKE_KEY_HEADER=authorization
|
|
19
|
-
# WAKE_KEY_PREFIX=Bearer
|
|
19
|
+
# WAKE_KEY_PREFIX="Bearer "
|
|
20
20
|
# WAKE_BODY_TEMPLATE={"text":"{{summary}}"}
|
|
21
21
|
# Command shown in wake hints (CLI: --cli-command; default "npx -y a2a-exposed@latest"). For a source checkout:
|
|
22
22
|
# WAKE_CLI_COMMAND=node /path/to/a2a-exposed/cli/bin/a2a-exposed.mjs
|
package/worker/package.json
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"deploy": "cf deploy",
|
|
10
10
|
"pretest": "node scripts/stamp-version.mjs",
|
|
11
11
|
"test": "node --import ./test/register-hooks.mjs --test test/*.test.ts",
|
|
12
|
+
"test:coverage": "node scripts/stamp-version.mjs && node --import ./test/register-hooks.mjs --experimental-test-coverage --test-coverage-include=src/** --test-reporter=spec --test-reporter=lcov --test-reporter-destination=stdout --test-reporter-destination=../coverage/worker.lcov --test test/*.test.ts",
|
|
12
13
|
"typecheck": "cf workers types && tsc"
|
|
13
14
|
},
|
|
14
15
|
"engines": {
|
|
@@ -16,11 +17,11 @@
|
|
|
16
17
|
},
|
|
17
18
|
"devDependencies": {
|
|
18
19
|
"@cloudflare/vite-plugin": "beta",
|
|
19
|
-
"cf": "^1.0.0-beta.
|
|
20
|
-
"esbuild": "^0.28.
|
|
20
|
+
"cf": "^1.0.0-beta.18",
|
|
21
|
+
"esbuild": "^0.28.2",
|
|
21
22
|
"miniflare": "^5.20261006.0-alpha",
|
|
22
23
|
"typescript": "^7.0.2",
|
|
23
|
-
"vite": "^8.3.
|
|
24
|
+
"vite": "^8.3.4"
|
|
24
25
|
},
|
|
25
26
|
"dependencies": {
|
|
26
27
|
"tunnelfetch": "1.13.0"
|
package/worker/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { renderWake, redact, cloudflareErrorHint, wakeUrlIsTunnelHost, defaultDe
|
|
|
10
10
|
import { fetchCheckedWake } from "./wake-target.ts";
|
|
11
11
|
import * as P from "./pairing.ts";
|
|
12
12
|
import * as M from "./mcp.ts";
|
|
13
|
-
import { accountIdOf, DASHBOARD_ACTOR_HEADER, matchOwnerRpc, ownerResponseLeaks, scrubOwnerClaims, type OwnerRpcRequest, type OwnerRpcResult } from "./owner-rpc.ts";
|
|
13
|
+
import { accountIdOf, DASHBOARD_ACTOR_HEADER, matchOwnerRpc, ownerResponseLeaks, ownerRpcBody, scrubOwnerClaims, type OwnerRpcRequest, type OwnerRpcResult } from "./owner-rpc.ts";
|
|
14
14
|
import { pairingAgent, pairingEventTarget, postPairingEvent } from "./pairing-events.ts";
|
|
15
15
|
import { publicManageUrl, resolveTenant, type TenantContext, type WorkerBindings } from "./tenancy.ts";
|
|
16
16
|
import { exportRows, redactExport, REDACTED_SQL_SKIP, scrubSecretText, toSql } from "./export.ts";
|
|
@@ -1060,6 +1060,17 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1060
1060
|
}
|
|
1061
1061
|
}
|
|
1062
1062
|
if (seg[0] === "pairing") return await ownerPairing(req, ctx, ectx, m, seg.slice(1), body, trusted);
|
|
1063
|
+
if (seg[0] === "mcp" && seg[1] && !seg[2] && m === "POST" && (seg[1] === "send" || seg[1] === "reply" || seg[1] === "mark_working")) {
|
|
1064
|
+
const checked = ownerRpcBody(`POST /owner/mcp/${seg[1]}`, await body());
|
|
1065
|
+
if (!checked.ok) return json({ error: checked.error }, 400);
|
|
1066
|
+
try {
|
|
1067
|
+
const text = await runTool(ctx, ectx, seg[1], checked.value);
|
|
1068
|
+
return json({ text, isError: false });
|
|
1069
|
+
} catch (e: any) {
|
|
1070
|
+
if (e instanceof ToolError) return json({ text: `Error: ${e.message}`, isError: true });
|
|
1071
|
+
throw e;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1063
1074
|
if (seg[0] === "mcp" && !seg[1] && m === "GET") {
|
|
1064
1075
|
const rows = await ctx.DB.prepare("SELECT label, client_id, client_name, redirect_host, scope, created_at, last_used_at, revoked_at, refresh_expires_ms FROM mcp_grants ORDER BY created_at").all();
|
|
1065
1076
|
return json({ enabled: mcpOn(ctx), url: oauthUrls(ctx).mcp, grants: (rows.results as Json[]).map((r) => ({ ...r, refresh_expires_ms: undefined,
|
|
@@ -2282,7 +2293,8 @@ export async function runOwnerRpc(ctx: TenantContext, ectx: ExecutionContext, in
|
|
|
2282
2293
|
}
|
|
2283
2294
|
const matched = matchOwnerRpc(raw?.method, raw?.path, raw?.query);
|
|
2284
2295
|
if (!matched.ok) return refuse(matched.reason === "query" ? 400 : 404, matched.reason === "query" ? "query is not allowed" : "not found", matched.reason);
|
|
2285
|
-
|
|
2296
|
+
const parsedBody = ownerRpcBody(matched.route.name, raw?.body);
|
|
2297
|
+
if (!parsedBody.ok) return refuse(400, parsedBody.error, "body");
|
|
2286
2298
|
const actor = (raw as { actor?: unknown } | null)?.actor;
|
|
2287
2299
|
if (actor !== undefined && actor !== "human") return refuse(400, "actor is invalid", "actor");
|
|
2288
2300
|
const pairingDecision = matched.route.name === "POST /owner/pairing/:code/approve" || matched.route.name === "POST /owner/pairing/:code/deny";
|
|
@@ -2297,7 +2309,11 @@ export async function runOwnerRpc(ctx: TenantContext, ectx: ExecutionContext, in
|
|
|
2297
2309
|
const url = new URL(origin + matched.route.path + search);
|
|
2298
2310
|
const headers = new Headers({ "content-type": "application/json" });
|
|
2299
2311
|
if (actor === "human") headers.set(DASHBOARD_ACTOR_HEADER, "human");
|
|
2300
|
-
const req = new Request(url, {
|
|
2312
|
+
const req = new Request(url, {
|
|
2313
|
+
method: matched.route.method,
|
|
2314
|
+
headers,
|
|
2315
|
+
body: parsedBody.value === undefined ? undefined : JSON.stringify(parsedBody.value),
|
|
2316
|
+
});
|
|
2301
2317
|
let res: Response;
|
|
2302
2318
|
try { res = await handleOwner(req, ctx, ectx, url.pathname, url, true); }
|
|
2303
2319
|
catch (e: any) {
|
package/worker/src/owner-rpc.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Hosted owner RPC allowlist. The caller has checked that the account owns the tenant.
|
|
2
2
|
// This list is the only paths that call may run, and it is stricter than
|
|
3
3
|
// the owner HTTP API: routes that mint a peer token, return a setup secret, or accept a plaintext peer
|
|
4
|
-
// token are not here. GET /owner/export is here only because runOwnerRpc redacts it first.
|
|
4
|
+
// token are not here. GET /owner/export is here only because runOwnerRpc redacts it first.
|
|
5
|
+
// POST /owner/mcp/send, reply, and mark_working take a checked argument object. A peer token is not one of those arguments.
|
|
6
|
+
// Self-host never calls this.
|
|
5
7
|
|
|
6
8
|
import { ID_RE } from "./a2a.ts";
|
|
7
9
|
import { credentialIsMasked, previewHeaderPlain } from "./wake.ts";
|
|
@@ -39,7 +41,10 @@ export interface OwnerRpcRequest {
|
|
|
39
41
|
path: string;
|
|
40
42
|
/** Allowlisted query parameters only. */
|
|
41
43
|
query?: Record<string, string>;
|
|
42
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Tool arguments for `POST /owner/mcp/send`, `POST /owner/mcp/reply`, and `POST /owner/mcp/mark_working`.
|
|
46
|
+
* Any other call refuses a body.
|
|
47
|
+
*/
|
|
43
48
|
body?: unknown;
|
|
44
49
|
/**
|
|
45
50
|
* Approve or deny after a recent sign-in.
|
|
@@ -141,6 +146,9 @@ const ROUTES: RouteDef[] = [
|
|
|
141
146
|
{ method: "POST", name: "POST /owner/pairing/:code/deny", take: (s) => s.length === 4 && s[1] === "pairing" && USER_CODE.test(s[2]!) && s[3] === "deny", search: noQuery, path: (s) => `/owner/pairing/${s[2]}/deny` },
|
|
142
147
|
{ method: "GET", name: "GET /owner/mcp", take: (s) => s.length === 2 && s[1] === "mcp", search: noQuery, path: () => "/owner/mcp" },
|
|
143
148
|
{ method: "DELETE", name: "DELETE /owner/mcp/:label", take: (s) => s.length === 3 && s[1] === "mcp" && MCP_LABEL.test(s[2]!), search: noQuery, path: (s) => `/owner/mcp/${s[2]}` },
|
|
149
|
+
{ method: "POST", name: "POST /owner/mcp/send", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "send", search: noQuery, path: () => "/owner/mcp/send" },
|
|
150
|
+
{ method: "POST", name: "POST /owner/mcp/reply", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "reply", search: noQuery, path: () => "/owner/mcp/reply" },
|
|
151
|
+
{ method: "POST", name: "POST /owner/mcp/mark_working", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "mark_working", search: noQuery, path: () => "/owner/mcp/mark_working" },
|
|
144
152
|
{ method: "GET", name: "GET /owner/outbound-peers", take: (s) => s.length === 2 && s[1] === "outbound-peers", search: noQuery, path: () => "/owner/outbound-peers" },
|
|
145
153
|
{ method: "DELETE", name: "DELETE /owner/outbound-peers/:alias", take: (s) => s.length === 3 && s[1] === "outbound-peers" && ALIAS.test(s[2]!) && s[2] !== "." && s[2] !== "..", search: noQuery, path: (s) => `/owner/outbound-peers/${s[2]}` },
|
|
146
154
|
{ method: "GET", name: "GET /owner/facade", take: (s) => s.length === 2 && s[1] === "facade", search: noQuery, path: () => "/owner/facade" },
|
|
@@ -166,6 +174,63 @@ export function matchOwnerRpc(method: unknown, path: unknown, query: unknown): O
|
|
|
166
174
|
return { ok: true, route: { method, path: def.path(seg), search, name: def.name } };
|
|
167
175
|
}
|
|
168
176
|
|
|
177
|
+
/** Send text cap. The owner HTTP body limit is 1 MiB; this keeps one argument inside that. */
|
|
178
|
+
const TEXT_MAX = 100_000;
|
|
179
|
+
const REPLY_STATES = new Set(["completed", "input-required", "working", "failed", "rejected"]);
|
|
180
|
+
|
|
181
|
+
const MCP_ARGS: Record<string, { required: readonly string[]; optional: readonly string[] }> = {
|
|
182
|
+
"POST /owner/mcp/send": { required: ["to", "text"], optional: ["contextId", "taskId"] },
|
|
183
|
+
"POST /owner/mcp/reply": { required: ["taskId", "text"], optional: ["state"] },
|
|
184
|
+
"POST /owner/mcp/mark_working": { required: ["taskId"], optional: [] },
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
function mcpArgOk(key: string, value: string): string | null {
|
|
188
|
+
if (key === "to") return ALIAS.test(value) ? null : "to is invalid";
|
|
189
|
+
if (key === "text") {
|
|
190
|
+
if (!value.trim()) return "text is required";
|
|
191
|
+
if (value.length > TEXT_MAX) return "text is too long";
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
if (key === "taskId" || key === "contextId") return idOk(value) ? null : `${key} is invalid`;
|
|
195
|
+
if (key === "state") return REPLY_STATES.has(value) ? null : "state is invalid";
|
|
196
|
+
return "body is not allowed";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Body allowed on this owner RPC route.
|
|
201
|
+
* MCP send, reply, and mark_working receive a flat object of tool arguments.
|
|
202
|
+
* Every other route accepts only an absent body.
|
|
203
|
+
*/
|
|
204
|
+
export function ownerRpcBody(routeName: string, body: unknown): { ok: true; value?: Record<string, string> } | { ok: false; error: string } {
|
|
205
|
+
const spec = MCP_ARGS[routeName];
|
|
206
|
+
if (!spec) {
|
|
207
|
+
if (body !== undefined && body !== null) return { ok: false, error: "this call takes no body" };
|
|
208
|
+
return { ok: true };
|
|
209
|
+
}
|
|
210
|
+
if (body == null || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: "body must be an object" };
|
|
211
|
+
const allowed = new Set([...spec.required, ...spec.optional]);
|
|
212
|
+
const keys = Object.keys(body);
|
|
213
|
+
if (keys.length > allowed.size || keys.some((key) => !allowed.has(key))) return { ok: false, error: "body is not allowed" };
|
|
214
|
+
const value: Record<string, string> = {};
|
|
215
|
+
for (const key of spec.required) {
|
|
216
|
+
const item = (body as Record<string, unknown>)[key];
|
|
217
|
+
if (item == null || item === "") return { ok: false, error: `${key} is required` };
|
|
218
|
+
if (typeof item !== "string") return { ok: false, error: `${key} is invalid` };
|
|
219
|
+
const why = mcpArgOk(key, item);
|
|
220
|
+
if (why) return { ok: false, error: why };
|
|
221
|
+
value[key] = item;
|
|
222
|
+
}
|
|
223
|
+
for (const key of spec.optional) {
|
|
224
|
+
if (!Object.prototype.hasOwnProperty.call(body, key)) continue;
|
|
225
|
+
const item = (body as Record<string, unknown>)[key];
|
|
226
|
+
if (typeof item !== "string" || !item) return { ok: false, error: `${key} is invalid` };
|
|
227
|
+
const why = mcpArgOk(key, item);
|
|
228
|
+
if (why) return { ok: false, error: why };
|
|
229
|
+
value[key] = item;
|
|
230
|
+
}
|
|
231
|
+
return { ok: true, value };
|
|
232
|
+
}
|
|
233
|
+
|
|
169
234
|
function stringLeaks(value: string, key: string | null): boolean {
|
|
170
235
|
if (TOKEN_RE.test(value) || SETUP_RE.test(value) || USERINFO_RE.test(value)) return true;
|
|
171
236
|
return key !== null && SECRET_KEYS.has(key.toLowerCase()) && value.length >= 16;
|
package/worker/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Inbox build. Keep this equal to `version` in cli/package.json. */
|
|
2
|
-
export const VERSION = "0.8.
|
|
2
|
+
export const VERSION = "0.8.12";
|
package/worker/test/mcp.test.ts
CHANGED
|
@@ -492,6 +492,43 @@ test("tools: send / poll_outbound through synced peers; each peer's token stays
|
|
|
492
492
|
assert.equal((await s.owner("GET", "/owner/outbound-peers")).data.length, 1);
|
|
493
493
|
});
|
|
494
494
|
|
|
495
|
+
test("POST /owner/mcp/send, reply, and mark_working use the owner token", async (t) => {
|
|
496
|
+
const s = setup(); t.after(s.restore);
|
|
497
|
+
const a = await s.connect();
|
|
498
|
+
await s.owner("PUT", "/owner/outbound-peers/alpha", { url: "https://alpha.example.org", token: "tok-alpha" });
|
|
499
|
+
const noAuth = await s.call("POST", "/owner/mcp/send", { json: { to: "alpha", text: "hi" } });
|
|
500
|
+
assert.equal(noAuth.status, 401);
|
|
501
|
+
const mcpTok = await s.call("POST", "/owner/mcp/send", { json: { to: "alpha", text: "hi" }, headers: { authorization: `Bearer ${a.access_token}` } });
|
|
502
|
+
assert.equal(mcpTok.status, 401);
|
|
503
|
+
const peerTok = "a2aow_" + "c".repeat(43);
|
|
504
|
+
const peer = await s.call("POST", "/owner/mcp/send", { json: { to: "alpha", text: "hi" }, headers: { authorization: `Bearer ${peerTok}` } });
|
|
505
|
+
assert.equal(peer.status, 401);
|
|
506
|
+
assert.equal(s.peerCalls.length, 0, "a refused caller does not send");
|
|
507
|
+
const extra = await s.owner("POST", "/owner/mcp/send", { to: "alpha", text: "hi", token: "tok-alpha" });
|
|
508
|
+
assert.equal(extra.status, 400);
|
|
509
|
+
assert.equal(s.peerCalls.length, 0, "a token field is not a send");
|
|
510
|
+
const sent = await s.owner("POST", "/owner/mcp/send", { to: "alpha", text: "status?", contextId: "ctx-owner" });
|
|
511
|
+
assert.equal(sent.status, 200, sent.text);
|
|
512
|
+
assert.equal(sent.data.isError, false);
|
|
513
|
+
assert.match(sent.data.text, /task pt-alpha/);
|
|
514
|
+
assert.equal(sent.text.includes("tok-alpha"), false);
|
|
515
|
+
assert.equal(s.peerCalls.at(-1)!.auth, "Bearer tok-alpha");
|
|
516
|
+
assert.equal(s.peerCalls.at(-1)!.body.params.message.contextId, "ctx-owner");
|
|
517
|
+
|
|
518
|
+
const inbound = await s.inbound("barry", "please reply");
|
|
519
|
+
const taskId = inbound.task.id as string;
|
|
520
|
+
const working = await s.owner("POST", "/owner/mcp/mark_working", { taskId });
|
|
521
|
+
assert.equal(working.status, 200, working.text);
|
|
522
|
+
assert.equal(working.data.isError, false);
|
|
523
|
+
assert.match(working.data.text, new RegExp(`Task ${taskId} is now working`));
|
|
524
|
+
const replied = await s.owner("POST", "/owner/mcp/reply", { taskId, text: "done", state: "completed" });
|
|
525
|
+
assert.equal(replied.status, 200, replied.text);
|
|
526
|
+
assert.equal(replied.data.isError, false);
|
|
527
|
+
assert.equal((await s.owner("GET", `/owner/tasks/${taskId}`)).data.status.state, "completed");
|
|
528
|
+
const mcpReply = await s.call("POST", "/owner/mcp/reply", { json: { taskId, text: "no" }, headers: { authorization: `Bearer ${a.access_token}` } });
|
|
529
|
+
assert.equal(mcpReply.status, 401);
|
|
530
|
+
});
|
|
531
|
+
|
|
495
532
|
test("peer token encryption round-trips and is bound to the alias", async () => {
|
|
496
533
|
const sealed = await M.sealPeerToken("owner", "alpha", "secret");
|
|
497
534
|
assert.equal(await M.openPeerToken("owner", "alpha", sealed), "secret");
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Hosted owner RPC for send, reply, and mark_working. The caller has already checked that the account owns the tenant.
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { sha256 } from "../src/a2a.ts";
|
|
5
|
+
import { dispatch, runOwnerRpc } from "../src/index.ts";
|
|
6
|
+
import { hostedTenantContext, resolveTenant, type WorkerBindings } from "../src/tenancy.ts";
|
|
7
|
+
import { d1 } from "./d1.ts";
|
|
8
|
+
|
|
9
|
+
const BASE = "https://carol.example.com";
|
|
10
|
+
const ectx = { waitUntil() {}, passThroughOnException() {} };
|
|
11
|
+
|
|
12
|
+
test("hosted control plane send, reply, and mark_working", async (t) => {
|
|
13
|
+
const DB = d1(new URL("../migrations/", import.meta.url));
|
|
14
|
+
const ownerToken = "hosted-owner-token";
|
|
15
|
+
const hash = await sha256(ownerToken);
|
|
16
|
+
const env = {
|
|
17
|
+
DB, TENANCY: "host", TENANT_SECRETS_KEY: "platform-key", PUBLIC_URL: BASE, OWNER_TOKEN: "platform-owner",
|
|
18
|
+
} as WorkerBindings;
|
|
19
|
+
const ctx = hostedTenantContext(env, {
|
|
20
|
+
id: "ten_carol", db: DB, publicUrl: BASE, ownerTokenHash: hash, status: "active",
|
|
21
|
+
config: { AGENT_NAME: "Carol" },
|
|
22
|
+
});
|
|
23
|
+
const peerCalls: { host: string; auth: string | null; body: any }[] = [];
|
|
24
|
+
const origFetch = globalThis.fetch;
|
|
25
|
+
globalThis.fetch = (async (input: any, init: any = {}) => {
|
|
26
|
+
const u = new URL(String(input));
|
|
27
|
+
if (u.pathname === "/.well-known/agent-card.json")
|
|
28
|
+
return Response.json({ name: u.host, supportedInterfaces: [{ url: `https://${u.host}/a2a`, protocolBinding: "JSONRPC", protocolVersion: "1.0" }] });
|
|
29
|
+
if (u.pathname === "/a2a") {
|
|
30
|
+
const body = JSON.parse(init.body);
|
|
31
|
+
const auth = new Headers(init.headers).get("authorization");
|
|
32
|
+
peerCalls.push({ host: u.host, auth, body });
|
|
33
|
+
if (auth !== `Bearer tok-${u.host.split(".")[0]}`) return new Response("no", { status: 401 });
|
|
34
|
+
if (body.method === "SendMessage") return Response.json({ jsonrpc: "2.0", id: body.id, result: { task: { id: `pt-${u.host.split(".")[0]}`, contextId: "ctx-out-1", status: { state: "TASK_STATE_SUBMITTED" } } } });
|
|
35
|
+
}
|
|
36
|
+
return new Response("not found", { status: 404 });
|
|
37
|
+
}) as typeof fetch;
|
|
38
|
+
t.after(() => { globalThis.fetch = origFetch; });
|
|
39
|
+
|
|
40
|
+
const http = async (method: string, path: string, auth: string | null, json?: unknown, headers: Record<string, string> = {}) => {
|
|
41
|
+
const h: Record<string, string> = { "content-type": "application/json", ...headers };
|
|
42
|
+
if (auth) h.authorization = auth;
|
|
43
|
+
const res = await dispatch(new Request(BASE + path, {
|
|
44
|
+
method, headers: h, body: json === undefined ? undefined : JSON.stringify(json),
|
|
45
|
+
}), ctx, ectx as any);
|
|
46
|
+
const text = await res.text();
|
|
47
|
+
let data: any = text;
|
|
48
|
+
try { data = JSON.parse(text); } catch { /* plain text */ }
|
|
49
|
+
return { status: res.status, data, text };
|
|
50
|
+
};
|
|
51
|
+
const rpc = (path: string, body?: unknown, extra: Record<string, unknown> = {}) =>
|
|
52
|
+
runOwnerRpc(ctx, ectx as any, { accountId: "acct_carol", method: "POST", path, ...(body === undefined ? {} : { body }), ...extra } as any);
|
|
53
|
+
|
|
54
|
+
const single = await runOwnerRpc(resolveTenant({ DB, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret" } as WorkerBindings), ectx as any, {
|
|
55
|
+
accountId: "acct_carol", method: "POST", path: "/owner/mcp/send", body: { to: "alpha", text: "hi" },
|
|
56
|
+
});
|
|
57
|
+
assert.equal(single.status, 404, "self-host keeps the bearer owner API");
|
|
58
|
+
|
|
59
|
+
assert.equal((await http("POST", "/owner/mcp/send", null, { to: "alpha", text: "hi" })).status, 401);
|
|
60
|
+
assert.equal((await http("POST", "/owner/mcp/send", "Bearer platform-owner", { to: "alpha", text: "hi" })).status, 401);
|
|
61
|
+
assert.equal((await http("POST", "/owner/mcp/send", "Bearer a2aow_" + "c".repeat(43), { to: "alpha", text: "hi" })).status, 401);
|
|
62
|
+
const synced = await http("PUT", "/owner/outbound-peers/alpha", `Bearer ${ownerToken}`, { url: "https://alpha.example.org", token: "tok-alpha" });
|
|
63
|
+
assert.equal(synced.status, 200, synced.text);
|
|
64
|
+
|
|
65
|
+
const missing = await rpc("/owner/mcp/send");
|
|
66
|
+
assert.equal(missing.status, 400);
|
|
67
|
+
assert.match(String((missing.body as { error?: string }).error), /body must be an object/);
|
|
68
|
+
const sneaky = await rpc("/owner/mcp/send", { to: "alpha", text: "hi", token: "tok-alpha" });
|
|
69
|
+
assert.equal(sneaky.status, 400);
|
|
70
|
+
assert.equal(peerCalls.length, 0);
|
|
71
|
+
const human = await rpc("/owner/mcp/send", { to: "alpha", text: "hi" }, { actor: "human" });
|
|
72
|
+
assert.equal(human.status, 400);
|
|
73
|
+
assert.equal(peerCalls.length, 0);
|
|
74
|
+
const badAccount = await runOwnerRpc(ctx, ectx as any, {
|
|
75
|
+
accountId: "../acct", method: "POST", path: "/owner/mcp/send", body: { to: "alpha", text: "hi" },
|
|
76
|
+
});
|
|
77
|
+
assert.equal(badAccount.status, 400);
|
|
78
|
+
assert.equal(peerCalls.length, 0);
|
|
79
|
+
|
|
80
|
+
const sent = await rpc("/owner/mcp/send", { to: "alpha", text: "status?", contextId: "ctx-hosted" });
|
|
81
|
+
assert.equal(sent.status, 200, JSON.stringify(sent.body));
|
|
82
|
+
const sentBody = sent.body as { text?: string; isError?: boolean };
|
|
83
|
+
assert.equal(sentBody.isError, false);
|
|
84
|
+
assert.match(sentBody.text || "", /task pt-alpha/);
|
|
85
|
+
assert.equal(JSON.stringify(sent.body).includes("tok-alpha"), false);
|
|
86
|
+
assert.equal(JSON.stringify(sent.body).includes(ownerToken), false);
|
|
87
|
+
assert.equal(peerCalls.at(-1)!.host, "alpha.example.org");
|
|
88
|
+
assert.equal(peerCalls.at(-1)!.auth, "Bearer tok-alpha");
|
|
89
|
+
assert.equal(peerCalls.at(-1)!.body.params.message.parts[0].text, "status?");
|
|
90
|
+
assert.equal(peerCalls.at(-1)!.body.params.message.contextId, "ctx-hosted");
|
|
91
|
+
|
|
92
|
+
const viaHttp = await http("POST", "/owner/mcp/send", `Bearer ${ownerToken}`, { to: "alpha", text: "from the owner token" });
|
|
93
|
+
assert.equal(viaHttp.status, 200, viaHttp.text);
|
|
94
|
+
assert.equal(viaHttp.data.isError, false);
|
|
95
|
+
assert.equal(viaHttp.text.includes("tok-alpha"), false);
|
|
96
|
+
assert.equal(peerCalls.at(-1)!.body.params.message.parts[0].text, "from the owner token");
|
|
97
|
+
|
|
98
|
+
const withBody = await runOwnerRpc(ctx, ectx as any, { accountId: "acct_carol", method: "GET", path: "/owner/peers", body: { label: "bob" } });
|
|
99
|
+
assert.equal(withBody.status, 400);
|
|
100
|
+
assert.equal((withBody.body as { error?: string }).error, "this call takes no body");
|
|
101
|
+
const minted = await rpc("/owner/peers", { label: "bob" });
|
|
102
|
+
assert.equal(minted.status, 404);
|
|
103
|
+
|
|
104
|
+
const issued = await http("POST", "/owner/peers", `Bearer ${ownerToken}`, { label: "ada" });
|
|
105
|
+
assert.match(issued.data.token || "", /^a2aow_/);
|
|
106
|
+
const inbound = await http("POST", "/", `Bearer ${issued.data.token}`, {
|
|
107
|
+
jsonrpc: "2.0", id: 1, method: "message/send",
|
|
108
|
+
params: { message: { kind: "message", role: "user", messageId: "m-ada", parts: [{ kind: "text", text: "hello" }] } },
|
|
109
|
+
}, { "a2a-version": "0.3" });
|
|
110
|
+
assert.equal(inbound.status, 200, inbound.text);
|
|
111
|
+
const taskId = inbound.data.result.id as string;
|
|
112
|
+
assert.ok(taskId);
|
|
113
|
+
const working = await rpc("/owner/mcp/mark_working", { taskId });
|
|
114
|
+
assert.equal(working.status, 200, JSON.stringify(working.body));
|
|
115
|
+
assert.match((working.body as { text?: string }).text || "", new RegExp(`Task ${taskId} is now working`));
|
|
116
|
+
const replied = await rpc("/owner/mcp/reply", { taskId, text: "done", state: "completed" });
|
|
117
|
+
assert.equal(replied.status, 200, JSON.stringify(replied.body));
|
|
118
|
+
assert.equal((replied.body as { isError?: boolean }).isError, false);
|
|
119
|
+
const task = await http("GET", `/owner/tasks/${taskId}`, `Bearer ${ownerToken}`);
|
|
120
|
+
assert.equal(task.data.status.state, "completed");
|
|
121
|
+
const unknown = await rpc("/owner/mcp/send", { to: "missing", text: "hi" });
|
|
122
|
+
assert.equal(unknown.status, 200);
|
|
123
|
+
assert.equal((unknown.body as { isError?: boolean }).isError, true);
|
|
124
|
+
assert.match((unknown.body as { text?: string }).text || "", /no peer "missing"/);
|
|
125
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Owner RPC allowlist and response filter. No Durable Object: the hosted test calls TenantStore.owner.
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
import assert from "node:assert/strict";
|
|
4
|
-
import { accountIdOf, matchOwnerRpc, ownerResponseLeaks, scrubOwnerClaims } from "../src/owner-rpc.ts";
|
|
4
|
+
import { accountIdOf, matchOwnerRpc, ownerResponseLeaks, ownerRpcBody, scrubOwnerClaims } from "../src/owner-rpc.ts";
|
|
5
5
|
|
|
6
6
|
test("owner RPC allowlist is the management reads and the calls that do not return a secret", () => {
|
|
7
7
|
const ok = (method: string, path: string, query?: Record<string, string>) => {
|
|
@@ -22,6 +22,9 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
|
|
|
22
22
|
assert.equal(ok("POST", "/owner/pairing/wdjb4827/deny")?.name, "POST /owner/pairing/:code/deny");
|
|
23
23
|
assert.equal(ok("GET", "/owner/mcp")?.name, "GET /owner/mcp");
|
|
24
24
|
assert.equal(ok("DELETE", "/owner/mcp/mcp-claude")?.path, "/owner/mcp/mcp-claude");
|
|
25
|
+
assert.equal(ok("POST", "/owner/mcp/send")?.name, "POST /owner/mcp/send");
|
|
26
|
+
assert.equal(ok("POST", "/owner/mcp/reply")?.path, "/owner/mcp/reply");
|
|
27
|
+
assert.equal(ok("POST", "/owner/mcp/mark_working")?.name, "POST /owner/mcp/mark_working");
|
|
25
28
|
assert.equal(ok("GET", "/owner/outbound-peers")?.name, "GET /owner/outbound-peers");
|
|
26
29
|
assert.equal(ok("DELETE", "/owner/outbound-peers/ada.bot")?.path, "/owner/outbound-peers/ada.bot");
|
|
27
30
|
assert.equal(ok("GET", "/owner/facade")?.name, "GET /owner/facade");
|
|
@@ -44,6 +47,9 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
|
|
|
44
47
|
assert.deepEqual(refused("GET", "/owner/log", { n: "10" }), { ok: false, reason: "query" });
|
|
45
48
|
assert.deepEqual(refused("POST", "/owner/purge"), { ok: false, reason: "path" });
|
|
46
49
|
assert.deepEqual(refused("POST", "/owner/tasks/task-1/reply"), { ok: false, reason: "path" });
|
|
50
|
+
assert.deepEqual(refused("POST", "/owner/mcp/send", { to: "alpha" }), { ok: false, reason: "query" });
|
|
51
|
+
assert.deepEqual(refused("POST", "/owner/mcp/poll_outbound"), { ok: false, reason: "path" });
|
|
52
|
+
assert.deepEqual(refused("PUT", "/owner/mcp/send"), { ok: false, reason: "path" });
|
|
47
53
|
assert.deepEqual(refused("POST", "/owner/history"), { ok: false, reason: "path" });
|
|
48
54
|
assert.deepEqual(refused("GET", "/owner/peers/../export"), { ok: false, reason: "path" });
|
|
49
55
|
assert.deepEqual(refused("GET", "/owner/%2e%2e/peers"), { ok: false, reason: "path" });
|
|
@@ -58,6 +64,41 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
|
|
|
58
64
|
assert.equal(accountIdOf(""), null);
|
|
59
65
|
});
|
|
60
66
|
|
|
67
|
+
test("owner RPC accepts a tool-argument body only on send, reply, and mark_working", () => {
|
|
68
|
+
const absent = ownerRpcBody("GET /owner/peers", undefined);
|
|
69
|
+
assert.equal(absent.ok, true);
|
|
70
|
+
assert.equal(absent.ok && absent.value, undefined);
|
|
71
|
+
assert.equal(ownerRpcBody("GET /owner/peers", null).ok, true);
|
|
72
|
+
assert.deepEqual(ownerRpcBody("GET /owner/peers", { label: "bob" }), { ok: false, error: "this call takes no body" });
|
|
73
|
+
assert.deepEqual(ownerRpcBody("POST /owner/wake/test", {}), { ok: false, error: "this call takes no body" });
|
|
74
|
+
|
|
75
|
+
const sent = ownerRpcBody("POST /owner/mcp/send", { to: "alpha", text: "status?", contextId: "ctx-1" });
|
|
76
|
+
assert.deepEqual(sent, { ok: true, value: { to: "alpha", text: "status?", contextId: "ctx-1" } });
|
|
77
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/send", { to: "alpha", text: "status?", taskId: "task-1" }), {
|
|
78
|
+
ok: true, value: { to: "alpha", text: "status?", taskId: "task-1" },
|
|
79
|
+
});
|
|
80
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/reply", { taskId: "task-1", text: "done", state: "input-required" }), {
|
|
81
|
+
ok: true, value: { taskId: "task-1", text: "done", state: "input-required" },
|
|
82
|
+
});
|
|
83
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/mark_working", { taskId: "task-1" }), { ok: true, value: { taskId: "task-1" } });
|
|
84
|
+
|
|
85
|
+
assert.equal(ownerRpcBody("POST /owner/mcp/send", undefined).ok, false);
|
|
86
|
+
assert.equal(ownerRpcBody("POST /owner/mcp/send", null).ok, false);
|
|
87
|
+
assert.equal(ownerRpcBody("POST /owner/mcp/send", []).ok, false);
|
|
88
|
+
assert.equal(ownerRpcBody("POST /owner/mcp/send", "hi").ok, false);
|
|
89
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/send", {}), { ok: false, error: "to is required" });
|
|
90
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/send", { to: "alpha" }), { ok: false, error: "text is required" });
|
|
91
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/send", { to: "https://alpha.example", text: "hi" }), { ok: false, error: "to is invalid" });
|
|
92
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/send", { to: "alpha", text: " " }), { ok: false, error: "text is required" });
|
|
93
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/send", { to: "alpha", text: "x".repeat(100_001) }), { ok: false, error: "text is too long" });
|
|
94
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/send", { to: "alpha", text: "hi", token: "tok-alpha" }), { ok: false, error: "body is not allowed" });
|
|
95
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/send", { to: "alpha", text: "hi", url: "https://evil.example" }), { ok: false, error: "body is not allowed" });
|
|
96
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/reply", { taskId: "task-1", text: "done", state: "canceled" }), { ok: false, error: "state is invalid" });
|
|
97
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/reply", { taskId: "task-1", text: "done", force: true }), { ok: false, error: "body is not allowed" });
|
|
98
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/mark_working", { taskId: ".." }), { ok: false, error: "taskId is invalid" });
|
|
99
|
+
assert.deepEqual(ownerRpcBody("POST /owner/mcp/mark_working", { taskId: "task-1", text: "no" }), { ok: false, error: "body is not allowed" });
|
|
100
|
+
});
|
|
101
|
+
|
|
61
102
|
test("owner RPC withholds peer tokens and decrypted secrets", () => {
|
|
62
103
|
const token = "a2aow_" + "a".repeat(43);
|
|
63
104
|
assert.equal(ownerResponseLeaks("a2aot_" + "Q".repeat(43)), true);
|
|
@@ -111,4 +152,4 @@ test("a token-shaped claim is blanked and the rest of the list is returned", ()
|
|
|
111
152
|
assert.equal(JSON.stringify(listed).includes(token), false);
|
|
112
153
|
assert.equal(JSON.stringify(listed).includes("t=" + "c".repeat(16)), false);
|
|
113
154
|
assert.equal(JSON.stringify(listed).includes("user:secret"), false);
|
|
114
|
-
});
|
|
155
|
+
});
|
package/worker/tsconfig.json
CHANGED