a2a-exposed 0.8.11 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.11",
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": {
@@ -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.11";
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);