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 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
- try { await owner("POST", "/owner/history", { contextId: ctx, ...entry }); }
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) await owner("POST", "/owner/outbound", { taskId: tid, contextId: ctx, peer: alias, endpoint: url, protocol: version, pushToken, task: obj });
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
- try { rec = await owner("GET", `/owner/outbound/${q(tid)}`); } catch { rec = {}; }
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 owner("PUT", `/owner/outbound/${q(tid)}`, { task: res });
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.10",
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
@@ -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.13",
20
- "esbuild": "^0.28.1",
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.0"
24
+ "vite": "^8.3.4"
24
25
  },
25
26
  "dependencies": {
26
27
  "tunnelfetch": "1.13.0"
@@ -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
- if (raw?.body !== undefined && raw.body !== null) return refuse(400, "this call takes no body", "body");
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, { method: matched.route.method, headers });
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) {
@@ -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. Self-host never calls this.
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
- /** Refused. The owner HTTP API is where a body is accepted. */
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;
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.10";
2
+ export const VERSION = "0.8.12";
@@ -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
+ });
@@ -19,4 +19,4 @@
19
19
  "cloudflare.config.ts",
20
20
  ".cloudflare/types"
21
21
  ]
22
- }
22
+ }