a2a-exposed 0.8.13 → 0.8.15

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.
@@ -399,6 +399,10 @@ export class TenantContext implements ConfigFields {
399
399
  readonly #approvalClientSecret?: string;
400
400
  /** Service binding for the issuer. Not a secret. Absent on a self-host deploy. */
401
401
  readonly #controlService?: ControlFetcher;
402
+ /** Hosted name directory. Absent on a self-host deploy, so same-zone calls stay on public fetch. */
403
+ readonly #tenantDirectory?: KvNamespace;
404
+ /** Hosted Durable Object namespace. Absent on a self-host deploy. */
405
+ readonly #tenantNamespace?: DoNamespace;
402
406
  #peerKey: Promise<CryptoKey | null> | null = null;
403
407
 
404
408
  constructor(init: {
@@ -406,6 +410,7 @@ export class TenantContext implements ConfigFields {
406
410
  gates: FeatureGates; limits: Record<string, unknown>; approval: Record<string, unknown>;
407
411
  config: ConfigFields; ownerToken?: string; ownerTokenHash?: string; secretsKey?: string;
408
412
  approvalClientSecret?: string; controlService?: ControlFetcher;
413
+ tenantDirectory?: KvNamespace; tenantNamespace?: DoNamespace;
409
414
  }) {
410
415
  this.id = init.id;
411
416
  this.mode = init.mode;
@@ -420,6 +425,8 @@ export class TenantContext implements ConfigFields {
420
425
  this.#secretsKey = init.secretsKey;
421
426
  this.#approvalClientSecret = init.approvalClientSecret;
422
427
  this.#controlService = init.controlService;
428
+ this.#tenantDirectory = init.tenantDirectory;
429
+ this.#tenantNamespace = init.tenantNamespace;
423
430
  Object.assign(this, init.config);
424
431
  }
425
432
 
@@ -433,6 +440,16 @@ export class TenantContext implements ConfigFields {
433
440
  return this.#controlService;
434
441
  }
435
442
 
443
+ /** Name directory for other tenants on this deployment. Undefined on a self-host deploy. */
444
+ tenantDirectory(): KvNamespace | undefined {
445
+ return this.#tenantDirectory;
446
+ }
447
+
448
+ /** Durable Object namespace for other tenants on this deployment. Undefined on a self-host deploy. */
449
+ tenantNamespace(): DoNamespace | undefined {
450
+ return this.#tenantNamespace;
451
+ }
452
+
436
453
  /**
437
454
  * Bucket id for feedback rate limits. Hosted mode sends the stored owner-token hash (already SHA-256).
438
455
  * Self-host has no separate hash to send and returns "".
@@ -514,5 +531,7 @@ export function hostedTenantContext(env: WorkerBindings, overlay: HostedOverlay)
514
531
  config, ownerTokenHash: hash || undefined, secretsKey: parsed.secretsKey,
515
532
  approvalClientSecret: approval.clientSecret,
516
533
  controlService: env.CONTROL_SERVICE,
534
+ tenantDirectory: env.TENANT_DIRECTORY,
535
+ tenantNamespace: env.TENANT_DO,
517
536
  });
518
537
  }
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.13";
2
+ export const VERSION = "0.8.15";
@@ -87,7 +87,7 @@ export async function lookupAddresses(host: string, signal?: AbortSignal): Promi
87
87
  /**
88
88
  * Why this URL must not be requested under public-https. Empty means it may be requested.
89
89
  * Names are resolved. Every returned address must be a public unicast address: private, loopback,
90
- * link-local (including metadata), CGNAT, multicast, and documentation ranges are refused.
90
+ * link-local (including metadata), CGNAT, multicast, benchmarking, Teredo, 6to4, and documentation ranges are refused.
91
91
  * A lookup that cannot be completed is refused.
92
92
  */
93
93
  export async function publicWakeProblem(url: string, lookup: AddressLookup = lookupAddresses): Promise<string> {
@@ -799,10 +799,16 @@ test("mcpLimitMessage names the A2A endpoint and the revoke command", () => {
799
799
  });
800
800
 
801
801
  test("isPublicIp: only global unicast addresses pass", () => {
802
- for (const ip of ["93.184.216.34", "1.1.1.1", "2606:2800:220:1::1", "[2a00:1450::1]"]) assert.equal(M.isPublicIp(ip), true, ip);
802
+ for (const ip of ["93.184.216.34", "1.1.1.1", "2606:2800:220:1::1", "[2a00:1450::1]",
803
+ "2000::1", "3ffe::1", "2620:4f:8000::1", "3fff:1000::1",
804
+ "2001:1::1", "2001:1::2", "2001:1::3", "2001:3::1", "2001:4:112::1", "2001:20::1", "2001:30::1"]) assert.equal(M.isPublicIp(ip), true, ip);
803
805
  for (const ip of ["10.1.2.3", "127.0.0.1", "172.16.0.1", "192.168.1.1", "169.254.169.254", "100.64.0.1", "0.0.0.0", "224.0.0.1", "255.255.255.255",
804
- "192.0.2.1", "198.18.0.1", "203.0.113.5", "300.1.1.1", "::1", "::", "fd00::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:7f00:1",
805
- "64:ff9b::a00:1", "2001:db8::1", "2002:a00:1::", "2001:0:4136:e378::1", "ff02::1", "example.org"]) assert.equal(M.isPublicIp(ip), false, ip);
806
+ "192.0.2.1", "198.18.0.1", "203.0.113.5", "300.1.1.1", "::1", "::", "fd00::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:7f00:1", "::ffff:8.8.8.8",
807
+ "64:ff9b::a00:1", "64:ff9b::8.8.8.8", "64:ff9b:1::8.8.8.8", "100::1", "5f00::1",
808
+ "2001:db8::1", "2001:DB8::1", "2001:0db8::1", "2002:a00:1::", "2002:0808:0808::", "2001:0:4136:e378::1", "2001::1",
809
+ "2001:2::1", "2001:0002::1", "2001:1::4", "2001:4:113::1", "2001:10::1",
810
+ "3fff::1", "3fff:0fff::1", "2::1", "20::1", "200::1",
811
+ "ff02::1", "example.org", "", "2001:::1", "1:2:3:4:5:6:7:8:9", "gggg::1"]) assert.equal(M.isPublicIp(ip), false, ip);
806
812
  });
807
813
 
808
814
  test("parseHttpResponse: status, headers, content-length and chunked bodies; malformed input refused", () => {
@@ -67,15 +67,15 @@ test("fresh database: every migration applies in order; a second run applies not
67
67
  test("database from the 0002 fix (0001-0003 recorded): only 0004 and later apply; pending requests keep working", () => {
68
68
  const db = existingDb([sql("0001_init.sql"), sql("0002_wake_budget.sql"), sql("0003_device_pairing.sql")], ["0001_init.sql", "0002_wake_budget.sql", "0003_device_pairing.sql"]);
69
69
  db.prepare("INSERT INTO device_requests (device_hash, user_code, status, created_ms, expires_ms, interval_s) VALUES ('h', 'WDJB4827', 'pending', 1, 2, 5)").run();
70
- assert.deepEqual(applyMigrations(db), ["0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql"]);
70
+ assert.deepEqual(applyMigrations(db), ["0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql"]);
71
71
  assert.equal((db.prepare("SELECT replaces_label FROM device_requests WHERE device_hash = 'h'").get() as any).replaces_label, null);
72
72
  });
73
73
 
74
74
  test("database from v0.2.0 (0001 and 0003 recorded): 0002 applies as a no-op and keeps the data", () => {
75
75
  const db = existingDb([sql("0001_init.sql"), sql("0003_device_pairing.sql")], ["0001_init.sql", "0003_device_pairing.sql"]);
76
76
  db.prepare("INSERT INTO wake_budget (hour, count) VALUES (1, 7)").run();
77
- assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql"]);
78
- assert.deepEqual(recorded(db), ["0001_init.sql", "0003_device_pairing.sql", "0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql"]);
77
+ assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql"]);
78
+ assert.deepEqual(recorded(db), ["0001_init.sql", "0003_device_pairing.sql", "0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql"]);
79
79
  assert.equal((db.prepare("SELECT count FROM wake_budget WHERE hour = 1").get() as any).count, 7);
80
80
  });
81
81
 
@@ -107,7 +107,7 @@ test("database from an earlier build without wake_budget (0001 and 0003 recorded
107
107
  assert.equal(before.data.error?.code, -32603, JSON.stringify(before.data));
108
108
  assert.equal(wakes.length, 0);
109
109
 
110
- assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql"]);
110
+ assert.deepEqual(applyMigrations(db), ["0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.sql", "0009_outbound_seen.sql"]);
111
111
  assert.deepEqual(columns(db, "wake_budget"), ["hour", "count"]);
112
112
  assert.deepEqual(applyMigrations(db), []);
113
113
 
@@ -0,0 +1,342 @@
1
+ // Push registration and inbox acknowledgement.
2
+ // A connector send attaches a push config only when the peer card advertises it, and stores the token
3
+ // hash before SendMessage returns. Inbox marks a reply seen only when the stored update is still the
4
+ // snapshot that response returned, on every storage backend this file runs against.
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { sha256 } from "../src/a2a.ts";
8
+ import { dispatch, runOwnerRpc } from "../src/index.ts";
9
+ import { hostedTenantContext, resolveTenant, type WorkerBindings } from "../src/tenancy.ts";
10
+ import { d1 } from "./d1.ts";
11
+
12
+ const MIGRATIONS = new URL("../migrations/", import.meta.url);
13
+ const ectx = { waitUntil(_p: Promise<unknown>) {}, passThroughOnException() {} };
14
+ const OWNER = "owner-secret";
15
+
16
+ type Row = { taskId?: string; contextId?: string; text?: string; kind?: string; state?: string | null };
17
+ type Hist = { event?: string; text?: string; state?: string | null };
18
+ type SendBody = { text?: string; isError?: boolean };
19
+
20
+ function reqUrl(input: RequestInfo | URL): string {
21
+ if (typeof input === "string") return input;
22
+ if (input instanceof URL) return input.href;
23
+ return input.url;
24
+ }
25
+
26
+ function single(url: string) {
27
+ const db = d1(MIGRATIONS);
28
+ const ctx = resolveTenant({ DB: db, PUBLIC_URL: url, OWNER_TOKEN: OWNER, AGENT_NAME: "Owner" } as WorkerBindings);
29
+ return { db, ctx };
30
+ }
31
+
32
+ async function owner(ctx: ReturnType<typeof resolveTenant>, method: string, path: string, body?: unknown) {
33
+ const res = await dispatch(new Request(ctx.PUBLIC_URL + path, {
34
+ method, headers: { authorization: `Bearer ${OWNER}`, "content-type": "application/json" },
35
+ body: body === undefined ? undefined : JSON.stringify(body),
36
+ }), ctx, ectx as never);
37
+ const text = await res.text();
38
+ let data: unknown = text;
39
+ try { data = JSON.parse(text); } catch { /* plain */ }
40
+ return { status: res.status, data, text };
41
+ }
42
+
43
+ function card(url: string, version: string, push: boolean | undefined) {
44
+ const body: Record<string, unknown> = {
45
+ name: "Peer",
46
+ url: `${url}/`,
47
+ supportedInterfaces: [{ url: `${url}/`, protocolBinding: "JSONRPC", protocolVersion: version }],
48
+ };
49
+ if (push !== undefined) body.capabilities = { pushNotifications: push };
50
+ return body;
51
+ }
52
+
53
+ function taskResult(id: string, contextId: string, version: string) {
54
+ const task = { id, contextId, status: { state: version.startsWith("1") ? "TASK_STATE_COMPLETED" : "completed" } };
55
+ return version.startsWith("1") ? { task } : task;
56
+ }
57
+
58
+ /** Run `inject` after the inbox's outbound read returns and before that read's caller continues. */
59
+ function duringOutboundRead(db: { prepare: (sql: string) => any }, inject: () => Promise<void>) {
60
+ const orig = db.prepare.bind(db);
61
+ let fired = false;
62
+ db.prepare = (sql: string) => {
63
+ const built = orig(sql);
64
+ if (fired || !sql.includes("FROM outbound WHERE updates_json")) return built;
65
+ const wrap = (s: any): any => {
66
+ const origAll = s.all.bind(s);
67
+ const origBind = s.bind.bind(s);
68
+ s.bind = (...a: unknown[]) => wrap(origBind(...a));
69
+ s.all = async () => {
70
+ const rows = await origAll();
71
+ if (fired) return rows;
72
+ fired = true;
73
+ db.prepare = orig;
74
+ await inject();
75
+ return rows;
76
+ };
77
+ return s;
78
+ };
79
+ return wrap(built);
80
+ };
81
+ }
82
+
83
+ async function seedReply(db: { prepare: (sql: string) => any }, taskId: string, contextId: string, token: string, text: string) {
84
+ const updates = JSON.stringify([{
85
+ receivedAt: "2026-01-01T00:00:00.000Z",
86
+ payload: { task: { id: taskId, contextId, status: { state: "completed", message: { parts: [{ text }] } } } },
87
+ }]);
88
+ await db.prepare(
89
+ `INSERT INTO outbound (task_id, context_id, peer, endpoint, protocol, push_token_hash, sent_at, updates_json, updates_seen)
90
+ VALUES (?, ?, 'ada', 'https://ada.example.net/', '1.0', ?, '2026-01-01T00:00:00.000Z', ?, 0)`,
91
+ ).bind(taskId, contextId, await sha256(token), updates).run();
92
+ await db.prepare("INSERT INTO wakes (context_id, last_sent_ms) VALUES (?, ?)").bind(contextId, Date.now()).run();
93
+ }
94
+
95
+ test("a push between the inbox read and the seen mark stays unseen", async () => {
96
+ const { db, ctx } = single("https://owner.example.net");
97
+ const token = "push-token-seen";
98
+ await seedReply(db, "task-seen", "ctx-seen", token, "first reply");
99
+ duringOutboundRead(db, async () => {
100
+ const push = await dispatch(new Request("https://owner.example.net/push", {
101
+ method: "POST",
102
+ headers: { "content-type": "application/json", "x-a2a-notification-token": token },
103
+ body: JSON.stringify({ task: { id: "task-seen", contextId: "ctx-seen", status: { state: "completed", message: { parts: [{ text: "newer reply" }] } } } }),
104
+ }), ctx, ectx as never);
105
+ assert.equal(push.status, 200);
106
+ });
107
+ const first = await owner(ctx, "GET", "/owner/inbox");
108
+ assert.equal(first.status, 200, first.text);
109
+ const shown = (first.data as Row[]).find((r) => r.kind === "outbound" && r.taskId === "task-seen");
110
+ assert.equal(shown?.text, "first reply");
111
+ const mid = await db.prepare("SELECT updates_seen, updates_json FROM outbound WHERE task_id = ?").bind("task-seen").first() as { updates_seen?: number; updates_json?: string };
112
+ assert.equal(Number(mid.updates_seen), 0);
113
+ assert.match(String(mid.updates_json), /newer reply/);
114
+ const wake = await db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").bind("ctx-seen").first() as { pending_json?: string } | null;
115
+ assert.match(String(wake?.pending_json), /outbound_update/);
116
+ const second = await owner(ctx, "GET", "/owner/inbox");
117
+ const again = (second.data as Row[]).find((r) => r.kind === "outbound" && r.taskId === "task-seen");
118
+ assert.equal(again?.text, "newer reply");
119
+ const done = await db.prepare("SELECT updates_seen FROM outbound WHERE task_id = ?").bind("task-seen").first() as { updates_seen?: number };
120
+ assert.equal(Number(done.updates_seen), 1);
121
+ const cleared = await db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").bind("ctx-seen").first() as { pending_json?: string | null };
122
+ assert.equal(cleared.pending_json, null);
123
+ });
124
+
125
+ test("hosted inbox acknowledgement also waits for the returned snapshot", async () => {
126
+ const db = d1(MIGRATIONS);
127
+ const hash = await sha256("hosted-owner");
128
+ const ctx = hostedTenantContext({
129
+ DB: db, TENANCY: "host", TENANT_SECRETS_KEY: "platform-key", PUBLIC_URL: "https://owner.example.net", OWNER_TOKEN: "platform-owner",
130
+ } as WorkerBindings, {
131
+ id: "ten_seen", db, publicUrl: "https://owner.example.net", ownerTokenHash: hash, status: "active", config: { AGENT_NAME: "Owner" },
132
+ });
133
+ const token = "push-token-rpc";
134
+ await seedReply(db, "task-rpc", "ctx-rpc", token, "first reply");
135
+ duringOutboundRead(db, async () => {
136
+ const push = await dispatch(new Request("https://owner.example.net/push", {
137
+ method: "POST",
138
+ headers: { "content-type": "application/json", "x-a2a-notification-token": token },
139
+ body: JSON.stringify({ task: { id: "task-rpc", contextId: "ctx-rpc", status: { state: "completed", message: { parts: [{ text: "newer reply" }] } } } }),
140
+ }), ctx, ectx as never);
141
+ assert.equal(push.status, 200);
142
+ });
143
+ const read = () => runOwnerRpc(ctx, ectx as never, { accountId: "acct_seen", method: "GET", path: "/owner/inbox" });
144
+ const first = await read();
145
+ assert.equal(first.status, 200, JSON.stringify(first.body));
146
+ const shown = (first.body as Row[]).find((r) => r.kind === "outbound" && r.taskId === "task-rpc");
147
+ assert.equal(shown?.text, "first reply");
148
+ const mid = await db.prepare("SELECT updates_seen FROM outbound WHERE task_id = ?").bind("task-rpc").first() as { updates_seen?: number };
149
+ assert.equal(Number(mid.updates_seen), 0);
150
+ const wake = await db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").bind("ctx-rpc").first() as { pending_json?: string } | null;
151
+ assert.match(String(wake?.pending_json), /outbound_update/);
152
+ const second = await read();
153
+ const again = (second.body as Row[]).find((r) => r.kind === "outbound" && r.taskId === "task-rpc");
154
+ assert.equal(again?.text, "newer reply");
155
+ const done = await db.prepare("SELECT updates_seen FROM outbound WHERE task_id = ?").bind("task-rpc").first() as { updates_seen?: number };
156
+ assert.equal(Number(done.updates_seen), 1);
157
+ });
158
+
159
+ test("send omits push unless the peer card advertises it", async (t) => {
160
+ const { db, ctx } = single("https://owner.example.net");
161
+ const seen: { host: string; configuration?: Record<string, unknown> }[] = [];
162
+ const orig = globalThis.fetch;
163
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
164
+ const url = new URL(reqUrl(input));
165
+ if (init?.method !== "POST") {
166
+ const push = url.host === "bea.example.net" ? false : undefined;
167
+ return new Response(JSON.stringify(card(`https://${url.host}`, "1.0", push)), { status: 200, headers: { "content-type": "application/json" } });
168
+ }
169
+ const body = JSON.parse(String(init.body)) as { params?: { configuration?: Record<string, unknown> } };
170
+ seen.push({ host: url.host, configuration: body.params?.configuration });
171
+ const pend = await db.prepare("SELECT task_id FROM outbound WHERE task_id LIKE 'pend.%'").all() as { results: unknown[] };
172
+ assert.equal(pend.results.length, 0);
173
+ const id = url.host === "bea.example.net" ? "task-false" : "task-missing";
174
+ return new Response(JSON.stringify({ jsonrpc: "2.0", id: "1", result: taskResult(id, "ctx-nopush", "1.0") }), { status: 200, headers: { "content-type": "application/json" } });
175
+ }) as typeof fetch;
176
+ t.after(() => { globalThis.fetch = orig; });
177
+
178
+ for (const [alias, url] of [["ada", "https://ada.example.net"], ["bea", "https://bea.example.net"]] as const) {
179
+ const synced = await owner(ctx, "PUT", `/owner/outbound-peers/${alias}`, { url });
180
+ assert.equal(synced.status, 200, synced.text);
181
+ }
182
+ for (const [alias, taskId] of [["ada", "task-missing"], ["bea", "task-false"]] as const) {
183
+ const sent = await owner(ctx, "POST", "/owner/mcp/send", { to: alias, text: "hello", contextId: "ctx-nopush" });
184
+ assert.equal(sent.status, 200, sent.text);
185
+ assert.equal((sent.data as SendBody).isError, false, sent.text);
186
+ assert.match((sent.data as SendBody).text || "", new RegExp(`task ${taskId}`));
187
+ const row = await db.prepare("SELECT push_token_hash FROM outbound WHERE task_id = ?").bind(taskId).first() as { push_token_hash?: string | null };
188
+ assert.equal(row.push_token_hash, null);
189
+ }
190
+ assert.equal(seen.length, 2);
191
+ for (const call of seen) {
192
+ assert.equal(call.configuration?.returnImmediately, true);
193
+ assert.equal("taskPushNotificationConfig" in (call.configuration || {}), false);
194
+ assert.equal("pushNotificationConfig" in (call.configuration || {}), false);
195
+ }
196
+ });
197
+
198
+ test("the push hash is stored before SendMessage returns, and a fast callback authenticates", async (t) => {
199
+ const { db, ctx } = single("https://owner.example.net");
200
+ const outputs: string[] = [];
201
+ let token = "";
202
+ const orig = globalThis.fetch;
203
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
204
+ const url = new URL(reqUrl(input));
205
+ if (init?.method !== "POST") {
206
+ const version = url.host === "cara.example.net" ? "0.3" : "1.0";
207
+ return new Response(JSON.stringify(card(`https://${url.host}`, version, true)), { status: 200, headers: { "content-type": "application/json" } });
208
+ }
209
+ const body = JSON.parse(String(init.body)) as { params?: { configuration?: { taskPushNotificationConfig?: { token?: string }; pushNotificationConfig?: { token?: string } } } };
210
+ const cfg = body.params?.configuration || {};
211
+ const push = cfg.taskPushNotificationConfig || cfg.pushNotificationConfig;
212
+ assert.equal(typeof push?.token, "string");
213
+ token = push!.token!;
214
+ if (url.host === "cara.example.net") {
215
+ assert.equal("taskPushNotificationConfig" in cfg, false);
216
+ assert.equal(typeof cfg.pushNotificationConfig?.token, "string");
217
+ } else {
218
+ assert.equal(typeof cfg.taskPushNotificationConfig?.token, "string");
219
+ }
220
+ const pend = await db.prepare("SELECT push_token_hash FROM outbound WHERE task_id LIKE 'pend.%'").all() as { results: { push_token_hash?: string }[] };
221
+ assert.equal(pend.results.length, 1);
222
+ assert.equal(pend.results[0]?.push_token_hash, await sha256(token));
223
+ const taskId = url.host === "cara.example.net" ? "task-03" : "task-race";
224
+ const contextId = url.host === "cara.example.net" ? "ctx-03" : "ctx-race";
225
+ if (url.host !== "cara.example.net") {
226
+ const pushRes = await dispatch(new Request("https://owner.example.net/push", {
227
+ method: "POST",
228
+ headers: { "content-type": "application/json", "x-a2a-notification-token": token },
229
+ body: JSON.stringify({ task: { id: taskId, contextId, status: { state: "completed", message: { parts: [{ text: "raced reply" }] } } } }),
230
+ }), ctx, ectx as never);
231
+ const pushText = await pushRes.text();
232
+ outputs.push(pushText);
233
+ assert.equal(pushRes.status, 200, pushText);
234
+ }
235
+ const version = url.host === "cara.example.net" ? "0.3" : "1.0";
236
+ return new Response(JSON.stringify({ jsonrpc: "2.0", id: "1", result: taskResult(taskId, contextId, version) }), { status: 200, headers: { "content-type": "application/json" } });
237
+ }) as typeof fetch;
238
+ t.after(() => { globalThis.fetch = orig; });
239
+
240
+ for (const [alias, url] of [["ada", "https://ada.example.net"], ["cara", "https://cara.example.net"]] as const) {
241
+ const synced = await owner(ctx, "PUT", `/owner/outbound-peers/${alias}`, { url });
242
+ assert.equal(synced.status, 200, synced.text);
243
+ }
244
+ const raced = await owner(ctx, "POST", "/owner/mcp/send", { to: "ada", text: "hello", contextId: "ctx-race" });
245
+ outputs.push(raced.text);
246
+ assert.equal((raced.data as SendBody).isError, false, raced.text);
247
+ const racedToken = token;
248
+ const racedRow = await db.prepare("SELECT task_id, push_token_hash, updates_json FROM outbound WHERE task_id = ?").bind("task-race").first() as { push_token_hash?: string; updates_json?: string };
249
+ assert.equal(racedRow.push_token_hash, await sha256(racedToken));
250
+ assert.match(String(racedRow.updates_json), /raced reply/);
251
+ const pend = await db.prepare("SELECT task_id FROM outbound WHERE task_id LIKE 'pend.%'").all() as { results: unknown[] };
252
+ assert.equal(pend.results.length, 0);
253
+ const history = await owner(ctx, "GET", "/owner/history/ctx-race");
254
+ assert.equal((history.data as Hist[]).some((r) => r.event === "push" && r.text === "raced reply"), true, history.text);
255
+ const inbox = await owner(ctx, "GET", "/owner/inbox");
256
+ outputs.push(inbox.text);
257
+ const reply = (inbox.data as Row[]).find((r) => r.kind === "outbound" && r.taskId === "task-race");
258
+ assert.equal(reply?.text, "raced reply");
259
+ assert.equal(outputs.join("\n").includes(racedToken), false);
260
+
261
+ const older = await owner(ctx, "POST", "/owner/mcp/send", { to: "cara", text: "hello", contextId: "ctx-03" });
262
+ outputs.push(older.text);
263
+ assert.equal((older.data as SendBody).isError, false, older.text);
264
+ const oldRow = await db.prepare("SELECT push_token_hash FROM outbound WHERE task_id = ?").bind("task-03").first() as { push_token_hash?: string };
265
+ assert.equal(oldRow.push_token_hash, await sha256(token));
266
+ assert.equal(outputs.join("\n").includes(token), false);
267
+ });
268
+
269
+ test("a failed send drops an empty pending push row and keeps a callback that already arrived", async (t) => {
270
+ const { db, ctx } = single("https://owner.example.net");
271
+ let mode: "throw" | "push-then-throw" = "throw";
272
+ const orig = globalThis.fetch;
273
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
274
+ const url = new URL(reqUrl(input));
275
+ if (init?.method !== "POST") return new Response(JSON.stringify(card(`https://${url.host}`, "1.0", true)), { status: 200, headers: { "content-type": "application/json" } });
276
+ const body = JSON.parse(String(init.body)) as { params?: { configuration?: { taskPushNotificationConfig?: { token?: string } } } };
277
+ const token = body.params?.configuration?.taskPushNotificationConfig?.token || "";
278
+ const pend = await db.prepare("SELECT push_token_hash FROM outbound WHERE task_id LIKE 'pend.%'").all() as { results: { push_token_hash?: string }[] };
279
+ assert.equal(pend.results.length, 1);
280
+ assert.equal(pend.results[0]?.push_token_hash, await sha256(token));
281
+ if (mode === "push-then-throw") {
282
+ const push = await dispatch(new Request("https://owner.example.net/push", {
283
+ method: "POST",
284
+ headers: { "content-type": "application/json", "x-a2a-notification-token": token },
285
+ body: JSON.stringify({ message: { taskId: "task-kept", contextId: "ctx-kept", role: "ROLE_AGENT", parts: [{ text: "kept reply" }] } }),
286
+ }), ctx, ectx as never);
287
+ assert.equal(push.status, 200);
288
+ }
289
+ throw new Error("peer down");
290
+ }) as typeof fetch;
291
+ t.after(() => { globalThis.fetch = orig; });
292
+ const synced = await owner(ctx, "PUT", "/owner/outbound-peers/ada", { url: "https://ada.example.net" });
293
+ assert.equal(synced.status, 200, synced.text);
294
+
295
+ const failed = await owner(ctx, "POST", "/owner/mcp/send", { to: "ada", text: "hello", contextId: "ctx-fail" });
296
+ assert.equal((failed.data as SendBody).isError, true, failed.text);
297
+ const gone = await db.prepare("SELECT task_id FROM outbound").all() as { results: unknown[] };
298
+ assert.equal(gone.results.length, 0);
299
+
300
+ mode = "push-then-throw";
301
+ const raced = await owner(ctx, "POST", "/owner/mcp/send", { to: "ada", text: "hello", contextId: "ctx-kept" });
302
+ assert.equal((raced.data as SendBody).isError, true, raced.text);
303
+ const kept = await db.prepare("SELECT task_id, updates_json FROM outbound").all() as { results: { task_id?: string; updates_json?: string }[] };
304
+ assert.equal(kept.results.length, 1);
305
+ assert.equal(kept.results[0]?.task_id, "task-kept");
306
+ assert.match(String(kept.results[0]?.updates_json), /kept reply/);
307
+ const history = await owner(ctx, "GET", "/owner/history/ctx-kept");
308
+ assert.equal((history.data as Hist[]).some((r) => r.event === "push" && r.text === "kept reply"), true, history.text);
309
+ });
310
+
311
+ test("message and artifactUpdate pushes supply inbox text", async () => {
312
+ const { db, ctx } = single("https://owner.example.net");
313
+ const cases = [
314
+ { id: "t-msg", context: "ctx-msg", token: "tok-msg", text: "from message", state: null as string | null, body: { message: { taskId: "t-msg", contextId: "ctx-msg", role: "ROLE_AGENT", parts: [{ text: "from message" }] } } },
315
+ { id: "t-art", context: "ctx-art", token: "tok-art", text: "from artifact", state: null, body: { artifactUpdate: { taskId: "t-art", contextId: "ctx-art", artifact: { artifactId: "a1", parts: [{ text: "from artifact" }] } } } },
316
+ { id: "t-raw", context: "ctx-raw", token: "tok-raw", text: "raw artifact", state: null, body: { kind: "artifact-update", taskId: "t-raw", contextId: "ctx-raw", artifact: { artifactId: "a2", parts: [{ kind: "text", text: "raw artifact" }] } } },
317
+ { id: "t-st", context: "ctx-st", token: "tok-st", text: "from status", state: "completed", body: { statusUpdate: { taskId: "t-st", contextId: "ctx-st", status: { state: "TASK_STATE_COMPLETED", message: { parts: [{ text: "from status" }] } } } } },
318
+ ];
319
+ for (const c of cases) {
320
+ await db.prepare(
321
+ `INSERT INTO outbound (task_id, context_id, peer, endpoint, protocol, push_token_hash, sent_at, updates_json, updates_seen)
322
+ VALUES (?, ?, 'ada', 'https://ada.example.net/', '1.0', ?, ?, '[]', 0)`,
323
+ ).bind(c.id, c.context, await sha256(c.token), `2026-01-01T00:00:0${cases.indexOf(c)}.000Z`).run();
324
+ const push = await dispatch(new Request("https://owner.example.net/push", {
325
+ method: "POST",
326
+ headers: { "content-type": "application/json", "x-a2a-notification-token": c.token },
327
+ body: JSON.stringify(c.body),
328
+ }), ctx, ectx as never);
329
+ assert.equal(push.status, 200, await push.clone().text());
330
+ }
331
+ const inbox = await owner(ctx, "GET", "/owner/inbox");
332
+ assert.equal(inbox.status, 200, inbox.text);
333
+ for (const c of cases) {
334
+ const row = (inbox.data as Row[]).find((r) => r.taskId === c.id);
335
+ assert.ok(row, inbox.text);
336
+ assert.equal(row.kind, "outbound");
337
+ assert.equal(row.text, c.text);
338
+ assert.equal(row.state, c.state);
339
+ const history = await owner(ctx, "GET", `/owner/history/${c.context}`);
340
+ assert.equal((history.data as Hist[]).some((r) => r.event === "push" && r.text === c.text && r.state === c.state), true, history.text);
341
+ }
342
+ });