a2a-exposed 0.8.36 → 0.8.38

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/hosted.mjs CHANGED
@@ -5,6 +5,7 @@ import { controlOrigin, deviceLogin } from "./account.mjs";
5
5
  import { CliError, upstreamUrlProblem } from "./a2a.mjs";
6
6
 
7
7
  export const SETUP_SCOPES = ["agents:read", "agents:create", "agents:owner-token", "wake:write", "wake:test"];
8
+ const SETUP_UNSUPPORTED = "This service does not expose scoped hosted setup capabilities. Check the --control-url service origin. Use the service dashboard to create an agent and configure Wake settings, or hosted MCP create_agent/set_wake with human confirmation and secret entry. Do not run init or deploy to repair a hosted address; no setup approval was requested.";
8
9
  const NEXT = "Run hosted capabilities --json to list scopes and presets; login --machine NAME --scopes SCOPES to approve setup.";
9
10
  const fail = (code, next) => { const error = new CliError(next); error.result = { ok: false, code, next }; throw error; };
10
11
  const printable = value => typeof value === "string" && value.length <= 4096 && !/[\x00-\x1f\x7f]/.test(value);
@@ -34,14 +35,16 @@ function originFor(o = {}) {
34
35
 
35
36
  async function request(origin, path, { token = "", method = "GET", body, fetchImpl = fetch } = {}) {
36
37
  let response, data;
38
+ const discovery = method === "GET" && path === "/api/v1/setup/capabilities";
37
39
  try {
38
40
  response = await fetchImpl(`${origin}${path}`, {
39
41
  method, redirect: "error", signal: AbortSignal.timeout(30_000),
40
42
  headers: { accept: "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { "content-type": "application/json" }) },
41
43
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
42
44
  });
43
- data = await response.json();
45
+ if (!(discovery && response.status === 404)) data = await response.json();
44
46
  } catch { fail("unavailable", "Hosted setup did not answer with JSON. Check the service and try again. Do not repeat a create request until hosted list confirms its result."); }
47
+ if (discovery && response.status === 404) fail("unsupported", SETUP_UNSUPPORTED);
45
48
  if (!data || typeof data !== "object" || Array.isArray(data)) fail("invalid_response", "The hosted service returned an invalid setup response.");
46
49
  if (!response.ok) {
47
50
  if (response.status === 429 && data.error === "too_many_references") {
@@ -70,7 +73,7 @@ async function request(origin, path, { token = "", method = "GET", body, fetchIm
70
73
 
71
74
  export async function capabilities(o = {}) {
72
75
  const data = await request(originFor(o), "/api/v1/setup/capabilities");
73
- if (!Array.isArray(data.scopes) || !Array.isArray(data.wake?.presets)) fail("unsupported", "This service does not expose hosted setup capabilities yet.");
76
+ if (!Array.isArray(data.scopes) || !Array.isArray(data.wake?.presets)) fail("unsupported", SETUP_UNSUPPORTED);
74
77
  return { ok: true, code: "capabilities", next: "Select explicit scopes and a wake preset from this listing.", scopes: data.scopes.filter(s => SETUP_SCOPES.includes(s)), presets: data.wake.presets.filter(s => /^[a-z-]+$/.test(s)), preflight: data.preflight === true, secret_reference: data.wake.secret_reference === true, wake_removal: data.wake.removal === true, secure_link_create: data.wake.secure_link_create === true };
75
78
  }
76
79
 
package/lib/main.mjs CHANGED
@@ -28,6 +28,7 @@ Setup options: --skip-install skips dependency installation when dependencies al
28
28
 
29
29
  Hosted setup:
30
30
  hosted capabilities --json list setup scopes, wake presets and service capabilities
31
+ unsupported/404: check --control-url; use dashboard or hosted MCP with human confirmation
31
32
  login --machine NAME --scopes SCOPES [--control-url URL] --json
32
33
  approve the explicit scopes using the printed website link once;
33
34
  save the scoped grant locally (mode 600), never print it
@@ -314,6 +315,8 @@ Outbound (agents you call)
314
315
  poll --to <alias|url> <taskId> [--proto 0.3|1.0]
315
316
  send/poll need only peer config; owner history/task storage is optional.
316
317
  Storage failures warn on stderr; the peer response still prints and exits 0
318
+ Self-hosted MCP: poll_outbound fetches current state; cancel_outbound asks the peer
319
+ to cancel a sent task. Ask the human before canceling consequential work
317
320
  outbound <taskId> stored state of a task you sent (incl. pushed updates)
318
321
 
319
322
  Owner
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.36",
3
+ "version": "0.8.38",
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": {
@@ -1147,7 +1147,7 @@ async function checkedPeerFetch(ctx: TenantContext, ectx: ExecutionContext, base
1147
1147
  try { baseUrl = new URL(base); target = new URL(url); } catch { throw new ToolError("peer endpoint is not a URL"); }
1148
1148
  if (target.origin !== baseUrl.origin) throw new ToolError("peer card endpoint refused: it is on a different origin");
1149
1149
  const why = peerUrlProblem(target.href);
1150
- if (why) throw new ToolError(`peer endpoint ${target.href} refused: ${why}`);
1150
+ if (why) throw new ToolError(`peer endpoint ${target.host} refused: ${why}`);
1151
1151
  const internal = await fetchTenant(ctx, target, { ...init, redirect: "manual" });
1152
1152
  if (internal) {
1153
1153
  if (internal.status >= 300 && internal.status < 400) throw new ToolError("peer endpoint refused: redirects are not allowed");
@@ -1563,10 +1563,12 @@ async function deliverReplyMessage(ctx: TenantContext, ectx: ExecutionContext, t
1563
1563
  const pushed = await notifyPeer(ctx, ectx, task, signal);
1564
1564
  const failed = pushed.find((x: Json) => !x.ok);
1565
1565
  const target = safeTarget(undefined, String((failed || pushed[0])?.url || ""));
1566
- const http = /HTTP (\d{3})/.exec(String((failed || pushed[0])?.info || ""));
1566
+ const info = String((failed || pushed[0])?.info || "");
1567
+ const http = /^HTTP (\d{3})$/.exec(info);
1568
+ const httpStatus = http && http[0] === info ? Number(http[1]) : undefined;
1567
1569
  return {
1568
1570
  status: failed ? "delivery-failed" : "delivered", method: "push", taskId: task.id, state,
1569
- ...(target ? { target } : {}), ...(http ? { httpStatus: Number(http[1]) } : {}),
1571
+ ...(target ? { target } : {}), ...(httpStatus !== undefined && httpStatus >= 100 && httpStatus <= 599 ? { httpStatus } : {}),
1570
1572
  detail: failed ? "A task push failed; the reply remains stored on the task." : "Delivered as the task's existing push update.",
1571
1573
  };
1572
1574
  }
@@ -2195,7 +2197,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
2195
2197
  }
2196
2198
  }
2197
2199
  if (seg[0] === "pairing") return await ownerPairing(req, ctx, ectx, m, seg.slice(1), body, trusted);
2198
- if (seg[0] === "mcp" && seg[1] && !seg[2] && m === "POST" && (seg[1] === "send" || seg[1] === "reply" || seg[1] === "mark_working" || seg[1] === "announce_address")) {
2200
+ if (seg[0] === "mcp" && seg[1] && !seg[2] && m === "POST" && (seg[1] === "send" || seg[1] === "reply" || seg[1] === "mark_working" || seg[1] === "announce_address" || seg[1] === "poll_outbound" || seg[1] === "cancel_outbound")) {
2199
2201
  const checked = ownerRpcBody(`POST /owner/mcp/${seg[1]}`, await body());
2200
2202
  if (!checked.ok) return json({ error: checked.error, ...(checked.code ? { code: checked.code } : {}) }, 400);
2201
2203
  try {
@@ -3565,8 +3567,15 @@ async function peerRpc(ctx: TenantContext, ectx: ExecutionContext, peer: { alias
3565
3567
  const text = (await res.text()).slice(0, 1 << 20);
3566
3568
  let data: Json = null;
3567
3569
  try { data = JSON.parse(text); } catch { /* below */ }
3568
- if (res.status !== 200 || !data || typeof data !== "object" || "error" in data)
3569
- throw new ToolError(`peer "${peer.alias}" answered HTTP ${res.status}${data?.error ? `: ${String(data.error.message || JSON.stringify(data.error)).slice(0, 300)}` : ""}`);
3570
+ if (res.status !== 200 || !data || typeof data !== "object" || "error" in data) {
3571
+ const rpc = data?.error;
3572
+ const code = Number.isSafeInteger(rpc?.code) ? `JSON-RPC ${rpc.code}` : "JSON-RPC error";
3573
+ const detail = typeof rpc?.message === "string" ? rpc.message.replace(/[\x00-\x1f\x7f]/g, " ") : "The peer returned an invalid response";
3574
+ const reason = rpc ? `${code}: ${detail}` : detail;
3575
+ const status = res.status === 200 ? "" : `HTTP ${res.status}: `;
3576
+ const safe = peer.token ? reason.split(peer.token).join("[redacted]") : reason;
3577
+ throw new ToolError(`peer "${peer.alias}" answered ${status}${safe.slice(0, 300)}`);
3578
+ }
3570
3579
  return data.result;
3571
3580
  }
3572
3581
 
@@ -3715,17 +3724,19 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
3715
3724
  return M.toolText(tid ? `Sent to ${peer.alias}: task ${tid} (${M.plainState(obj)}), context ${sentContext}. Check it later with poll_outbound.${warn} ${UNTRUSTED}`
3716
3725
  : `Sent to ${peer.alias}; it answered directly (context ${sentContext}). ${UNTRUSTED}`, obj);
3717
3726
  }
3718
- case "poll_outbound": {
3727
+ case "poll_outbound":
3728
+ case "cancel_outbound": {
3729
+ const cancel = name === "cancel_outbound";
3719
3730
  const tid = A.checkId(argStr(a, "taskId"), "taskId");
3720
3731
  const rec = must(await ownerCall(ctx, ectx, "GET", `/owner/outbound/${enc(tid)}`));
3721
3732
  const peer = await outboundPeer(ctx, rec.peer);
3722
3733
  if (!rec.endpoint) throw new ToolError("this task has no recorded endpoint");
3723
- const res = await peerRpc(ctx, ectx, peer, rec.endpoint, rec.protocol || "0.3", "tasks/get", "GetTask", { id: tid });
3734
+ const res = await peerRpc(ctx, ectx, peer, rec.endpoint, rec.protocol || "0.3", cancel ? "tasks/cancel" : "tasks/get", cancel ? "CancelTask" : "GetTask", { id: tid });
3724
3735
  await ownerCall(ctx, ectx, "PUT", `/owner/outbound/${enc(tid)}`, { task: res });
3725
3736
  let txt = A.textOf((res?.status || {}).message || {});
3726
3737
  const arts = (res?.artifacts || []).map((x: Json) => A.textOf(x)).join("\n");
3727
3738
  if (arts && arts !== txt) txt = (txt ? txt + "\n" : "") + arts;
3728
- await ownerCall(ctx, ectx, "POST", "/owner/history", { contextId: rec.contextId, dir: "in", peer: peer.alias, taskId: tid, event: "poll", state: M.plainState(res), text: txt });
3739
+ await ownerCall(ctx, ectx, "POST", "/owner/history", { contextId: rec.contextId, dir: "in", peer: peer.alias, taskId: tid, event: cancel ? "cancel" : "poll", state: M.plainState(res), text: txt });
3729
3740
  return M.toolText(`Task ${tid} at ${peer.alias}: ${M.plainState(res)}. ${UNTRUSTED}`, res);
3730
3741
  }
3731
3742
  case "list_peers": {
package/worker/src/mcp.ts CHANGED
@@ -354,6 +354,9 @@ export const TOOLS: { name: string; title: string; description: string; inputSch
354
354
  { name: "poll_outbound", title: "Check a sent task", annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
355
355
  description: `Fetch the current state of a task you sent to a peer (from send) and record it in the history. ${RULES}`,
356
356
  inputSchema: { type: "object", properties: { taskId: str("Task id returned by send") }, required: ["taskId"], additionalProperties: false } },
357
+ { name: "cancel_outbound", title: "Cancel a sent task", annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
358
+ description: `Ask the peer to cancel a task returned by send. Uses the recorded peer and endpoint, records the returned state in history, and does not cancel an inbound task. The peer can refuse cancellation. Ask the human before canceling consequential work. ${RULES}`,
359
+ inputSchema: { type: "object", properties: { taskId: str("Task id returned by send") }, required: ["taskId"], additionalProperties: false } },
357
360
  { name: "list_peers", title: "List peers", annotations: ro,
358
361
  description: "Peers you can send to (outbound, synced with `peers sync`) and the labels of agents holding a token for this A2A endpoint (inbound; who can message you). Token values are never shown. An inbound peer with no card URL has no callback yet.",
359
362
  inputSchema: { type: "object", properties: {}, additionalProperties: false } },
@@ -4,7 +4,7 @@
4
4
  // PUT /owner/outbound-peers/:alias is the one call that accepts a peer token. handleOwner seals it.
5
5
  // The response is { ok, alias, hasToken } and the token is not logged.
6
6
  // GET /owner/export is here only because runOwnerRpc redacts it first.
7
- // POST /owner/mcp/send, reply, and mark_working take a checked argument object. A peer token is not one of those arguments.
7
+ // POST /owner/mcp/send, reply, mark_working, poll_outbound, and cancel_outbound take a checked argument object. A peer token is not one of those arguments.
8
8
  // Self-host never calls this.
9
9
 
10
10
  import { ID_RE } from "./a2a.ts";
@@ -169,6 +169,8 @@ const ROUTES: RouteDef[] = [
169
169
  { 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]}` },
170
170
  { method: "POST", name: "POST /owner/mcp/send", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "send", search: noQuery, path: () => "/owner/mcp/send" },
171
171
  { method: "POST", name: "POST /owner/mcp/reply", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "reply", search: noQuery, path: () => "/owner/mcp/reply" },
172
+ { method: "POST", name: "POST /owner/mcp/poll_outbound", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "poll_outbound", search: noQuery, path: () => "/owner/mcp/poll_outbound" },
173
+ { method: "POST", name: "POST /owner/mcp/cancel_outbound", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "cancel_outbound", search: noQuery, path: () => "/owner/mcp/cancel_outbound" },
172
174
  { 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" },
173
175
  { method: "POST", name: "POST /owner/mcp/announce_address", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "announce_address", search: noQuery, path: () => "/owner/mcp/announce_address" },
174
176
  { method: "GET", name: "GET /owner/outbound-peers", take: (s) => s.length === 2 && s[1] === "outbound-peers", search: noQuery, path: () => "/owner/outbound-peers" },
@@ -204,6 +206,8 @@ const REPLY_STATES = new Set(["completed", "input-required", "working", "failed"
204
206
  const MCP_ARGS: Record<string, { required: readonly string[]; optional: readonly string[] }> = {
205
207
  "POST /owner/mcp/send": { required: ["to", "text"], optional: ["contextId", "taskId"] },
206
208
  "POST /owner/mcp/reply": { required: ["taskId", "text"], optional: ["state"] },
209
+ "POST /owner/mcp/poll_outbound": { required: ["taskId"], optional: [] },
210
+ "POST /owner/mcp/cancel_outbound": { required: ["taskId"], optional: [] },
207
211
  "POST /owner/mcp/mark_working": { required: ["taskId"], optional: [] },
208
212
  };
209
213
 
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.36";
2
+ export const VERSION = "0.8.38";
@@ -329,7 +329,7 @@ test("scope: the MCP token is not an owner or peer token, and peer/owner tokens
329
329
  assert.equal((await s.call("GET", "/mcp", { headers: { authorization: `Bearer ${a.access_token}` } })).status, 405);
330
330
  // tools can't administer tokens or pairings
331
331
  const names = (await s.mcp(a.access_token, "tools/list")).data.result.tools.map((x: any) => x.name);
332
- assert.deepEqual(names, ["inbox", "show_task", "history", "mark_working", "reply", "send", "poll_outbound", "list_peers", "announce_address", "pairing_requests", "send_feedback"]);
332
+ assert.deepEqual(names, ["inbox", "show_task", "history", "mark_working", "reply", "send", "poll_outbound", "cancel_outbound", "list_peers", "announce_address", "pairing_requests", "send_feedback"]);
333
333
  assert.ok(!names.some((n: string) => /approve|deny|token|revoke|issue|password/.test(n)));
334
334
  const unknown = await s.mcp(a.access_token, "tools/call", { name: "approve_pairing", arguments: { code: "X" } });
335
335
  assert.equal(unknown.data.error.code, -32602);
@@ -21,17 +21,27 @@ test("hosted control plane send, reply, and mark_working", async (t) => {
21
21
  config: { AGENT_NAME: "Carol" },
22
22
  });
23
23
  const peerCalls: { host: string; auth: string | null; body: any }[] = [];
24
+ let modern = true;
25
+ let cancelRefused = false;
24
26
  const origFetch = globalThis.fetch;
25
27
  globalThis.fetch = (async (input: any, init: any = {}) => {
26
28
  const u = new URL(String(input));
27
29
  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" }] });
30
+ return modern ? Response.json({ name: u.host, supportedInterfaces: [{ url: `https://${u.host}/a2a`, protocolBinding: "JSONRPC", protocolVersion: "1.0" }] }) : Response.json({ url: `https://${u.host}/a2a`, protocolVersion: "0.3" });
29
31
  if (u.pathname === "/a2a") {
30
32
  const body = JSON.parse(init.body);
31
33
  const auth = new Headers(init.headers).get("authorization");
32
34
  peerCalls.push({ host: u.host, auth, body });
33
35
  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" } } } });
36
+ if (body.method === "GetTask" || body.method === "tasks/get" || body.method === "CancelTask" || body.method === "tasks/cancel") {
37
+ assert.equal(new Headers(init.headers).get("a2a-version"), modern ? "1.0" : "0.3");
38
+ const cancel = body.method === "CancelTask" || body.method === "tasks/cancel";
39
+ if (cancel && cancelRefused) return Response.json({ jsonrpc: "2.0", id: body.id, error: { code: -32002, message: "Task cannot be canceled" } });
40
+ return Response.json({ jsonrpc: "2.0", id: body.id, result: { id: body.params.id, contextId: "ctx-out-1", status: { state: modern ? (cancel ? "TASK_STATE_CANCELED" : "TASK_STATE_WORKING") : (cancel ? "canceled" : "working") } } });
41
+ }
42
+ if (body.params?.message?.parts[0]?.text === "echo-token") return Response.json({ jsonrpc: "2.0", id: body.id, error: { code: -32603, message: "Rejected tok-alpha" } });
43
+ if (body.params?.message?.parts[0]?.text === "terminal") return Response.json({ jsonrpc: "2.0", id: body.id, error: { code: -32002, message: "Task is in a terminal state" } });
44
+ if (body.method === "SendMessage" || body.method === "message/send") return Response.json({ jsonrpc: "2.0", id: body.id, result: { task: { id: `pt-${u.host.split(".")[0]}${modern ? "" : "-03"}`, contextId: "ctx-out-1", status: { state: "TASK_STATE_SUBMITTED" } } } });
35
45
  }
36
46
  return new Response("not found", { status: 404 });
37
47
  }) as typeof fetch;
@@ -89,6 +99,47 @@ test("hosted control plane send, reply, and mark_working", async (t) => {
89
99
  assert.equal(peerCalls.at(-1)!.body.params.message.parts[0].text, "status?");
90
100
  assert.equal(peerCalls.at(-1)!.body.params.message.contextId, "ctx-hosted");
91
101
 
102
+ for (const version of [true, false]) {
103
+ modern = version;
104
+ const taskId = version ? "pt-alpha" : "pt-alpha-03";
105
+ await rpc("/owner/mcp/send", { to: "alpha", text: "status?" });
106
+ for (const tool of ["poll_outbound", "cancel_outbound"]) {
107
+ const before = peerCalls.length;
108
+ const missing = await rpc(`/owner/mcp/${tool}`, { taskId: "unknown" });
109
+ assert.equal((missing.body as any).isError, true);
110
+ assert.equal(peerCalls.length, before, "unknown task never calls a peer");
111
+ const denied = await http("POST", `/owner/mcp/${tool}`, "Bearer platform-owner", { taskId });
112
+ assert.equal(denied.status, 401);
113
+ const result = await rpc(`/owner/mcp/${tool}`, { taskId });
114
+ assert.equal(result.status, 200);
115
+ assert.equal((result.body as any).isError, false, JSON.stringify(result.body));
116
+ assert.match((result.body as any).text, tool === "poll_outbound" ? /working/ : /canceled/);
117
+ assert.equal(peerCalls.at(-1)!.auth, "Bearer tok-alpha");
118
+ assert.equal(peerCalls.at(-1)!.body.method, version ? (tool === "poll_outbound" ? "GetTask" : "CancelTask") : (tool === "poll_outbound" ? "tasks/get" : "tasks/cancel"));
119
+ assert.deepEqual(peerCalls.at(-1)!.body.params, { id: taskId });
120
+ assert.equal(JSON.stringify(result.body).includes("tok-alpha"), false);
121
+ const stored = await http("GET", `/owner/outbound/${taskId}`, `Bearer ${ownerToken}`);
122
+ assert.match(JSON.stringify(stored.data), tool === "poll_outbound" ? /WORKING|working/ : /CANCELED|canceled/);
123
+ }
124
+ cancelRefused = true;
125
+ const beforeRefusal = await http("GET", `/owner/outbound/${taskId}`, `Bearer ${ownerToken}`);
126
+ const refused = await rpc("/owner/mcp/cancel_outbound", { taskId });
127
+ assert.equal((refused.body as any).isError, true);
128
+ assert.match((refused.body as any).text, /JSON-RPC -32002: Task cannot be canceled/);
129
+ assert.deepEqual((await http("GET", `/owner/outbound/${taskId}`, `Bearer ${ownerToken}`)).data, beforeRefusal.data, "refused cancellation preserves the saved task");
130
+ cancelRefused = false;
131
+ const error = await rpc("/owner/mcp/send", { to: "alpha", text: "terminal" });
132
+ assert.equal((error.body as any).isError, true);
133
+ assert.match((error.body as any).text, /JSON-RPC -32002: Task is in a terminal state/);
134
+ assert.doesNotMatch((error.body as any).text, /HTTP 200/);
135
+ }
136
+ modern = true;
137
+ const echoed = await rpc("/owner/mcp/send", { to: "alpha", text: "echo-token" });
138
+ assert.equal((echoed.body as any).isError, true);
139
+ assert.doesNotMatch(JSON.stringify(echoed.body), /tok-alpha/);
140
+ const history = await http("GET", "/owner/history/ctx-out-1", `Bearer ${ownerToken}`);
141
+ assert.match(JSON.stringify(history.data), /"event":"cancel"/);
142
+
92
143
  const viaHttp = await http("POST", "/owner/mcp/send", `Bearer ${ownerToken}`, { to: "alpha", text: "from the owner token" });
93
144
  assert.equal(viaHttp.status, 200, viaHttp.text);
94
145
  assert.equal(viaHttp.data.isError, false);
@@ -54,7 +54,11 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
54
54
  assert.deepEqual(refused("POST", "/owner/purge"), { ok: false, reason: "path" });
55
55
  assert.deepEqual(refused("POST", "/owner/tasks/task-1/reply"), { ok: false, reason: "path" });
56
56
  assert.deepEqual(refused("POST", "/owner/mcp/send", { to: "alpha" }), { ok: false, reason: "query" });
57
- assert.deepEqual(refused("POST", "/owner/mcp/poll_outbound"), { ok: false, reason: "path" });
57
+ for (const tool of ["poll_outbound", "cancel_outbound"]) {
58
+ assert.equal(ok("POST", `/owner/mcp/${tool}`)?.path, `/owner/mcp/${tool}`);
59
+ assert.deepEqual(ownerRpcBody(`POST /owner/mcp/${tool}`, { taskId: "task-1" }), { ok: true, value: { taskId: "task-1" } });
60
+ for (const body of [{}, { taskId: ".." }, { taskId: "task-1", to: "other" }, { taskId: "task-1", token: "secret" }]) assert.equal(ownerRpcBody(`POST /owner/mcp/${tool}`, body).ok, false);
61
+ }
58
62
  assert.deepEqual(refused("PUT", "/owner/mcp/send"), { ok: false, reason: "path" });
59
63
  assert.deepEqual(refused("POST", "/owner/history"), { ok: false, reason: "path" });
60
64
  assert.deepEqual(refused("GET", "/owner/peers/../export"), { ok: false, reason: "path" });
@@ -259,3 +259,36 @@ for (const version of ["0.3", "1.0"]) {
259
259
  });
260
260
  }
261
261
  }
262
+
263
+
264
+ test("push status only accepts an exact HTTP result in the output schema range", async (t) => {
265
+ const s = await fixture(t as never);
266
+ const originalFetch = globalThis.fetch;
267
+ t.after(() => { globalThis.fetch = originalFetch; });
268
+ for (const value of [200, 307, 401, 599, 99, 600, 999, "HTTP 999", "prefix HTTP 401 suffix", "HTTP 401\n"]) {
269
+ globalThis.fetch = (async () => {
270
+ if (typeof value === "string") throw value;
271
+ const response = new Response(null);
272
+ Object.defineProperty(response, "status", { value });
273
+ return response;
274
+ }) as typeof fetch;
275
+ const taskId = await s.sendToBob("status " + value, crypto.randomUUID(), { id: "cfg", url: "https://push.example.net/update" });
276
+ const result = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "stored answer" });
277
+ const delivery = (result.body as any).structuredContent as Delivery;
278
+ assert.equal(delivery.httpStatus, typeof value === "number" && value >= 100 && value <= 599 ? value : undefined, String(value));
279
+ assert.equal(delivery.status, value === 200 ? "delivered" : "delivery-failed");
280
+ const stored = await s.dbB.prepare("SELECT state FROM tasks WHERE id = ?").bind(taskId).first() as { state: string };
281
+ assert.equal(stored.state, "completed");
282
+ }
283
+ });
284
+
285
+ test("direct reply refusal reports only the endpoint host", async (t) => {
286
+ const s = await fixture(t as never);
287
+ await s.dbB.prepare("UPDATE outbound_peers SET url = 'http://alice.example.com/private-path?private-query=yes' WHERE alias = 'alice'").run();
288
+ const taskId = await s.sendToBob("refused endpoint", "ctx-refused-url");
289
+ const result = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "stored answer" });
290
+ const delivery = (result.body as any).structuredContent;
291
+ assert.equal(delivery.status, "delivery-failed");
292
+ assert.match(delivery.detail, /peer endpoint alice\.example\.com refused/);
293
+ assert.doesNotMatch(JSON.stringify(result.body), /private-path|private-query|http:\/\//);
294
+ });