a2a-exposed 0.8.14 → 0.8.16
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 +3 -1
- package/lib/deploy.mjs +33 -6
- package/lib/main.mjs +6 -3
- package/package.json +1 -1
- package/worker/migrations/0009_outbound_seen.sql +2 -0
- package/worker/src/index.ts +276 -36
- package/worker/src/mcp.ts +2 -2
- package/worker/src/migrations.ts +2 -0
- package/worker/src/owner-rpc.ts +32 -7
- package/worker/src/tenancy.ts +19 -0
- package/worker/src/version.ts +1 -1
- package/worker/test/facade.test.ts +56 -1
- package/worker/test/migrations.test.ts +4 -4
- package/worker/test/outbound-push.test.ts +342 -0
- package/worker/test/outbound-reply.test.ts +538 -0
- package/worker/test/owner-rpc.test.ts +15 -1
- package/worker/test/version-header.test.ts +100 -0
package/worker/src/owner-rpc.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Hosted owner RPC allowlist. The caller has checked that the account owns the tenant.
|
|
2
2
|
// This list is the only paths that call may run, and it is stricter than
|
|
3
|
-
// the owner HTTP API: routes that mint a peer token
|
|
4
|
-
//
|
|
3
|
+
// the owner HTTP API: routes that mint a peer token or return a setup secret are not here.
|
|
4
|
+
// PUT /owner/outbound-peers/:alias is the one call that accepts a peer token. handleOwner seals it.
|
|
5
|
+
// The response is { ok, alias, hasToken } and the token is not logged.
|
|
6
|
+
// GET /owner/export is here only because runOwnerRpc redacts it first.
|
|
5
7
|
// POST /owner/mcp/send, reply, and mark_working take a checked argument object. A peer token is not one of those arguments.
|
|
6
8
|
// Self-host never calls this.
|
|
7
9
|
|
|
@@ -42,8 +44,8 @@ export interface OwnerRpcRequest {
|
|
|
42
44
|
/** Allowlisted query parameters only. */
|
|
43
45
|
query?: Record<string, string>;
|
|
44
46
|
/**
|
|
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
|
+
* Tool arguments for `POST /owner/mcp/send`, `POST /owner/mcp/reply`, and `POST /owner/mcp/mark_working`,
|
|
48
|
+
* or `{ url, token }` for `PUT /owner/outbound-peers/:alias`. Any other call refuses a body.
|
|
47
49
|
*/
|
|
48
50
|
body?: unknown;
|
|
49
51
|
/**
|
|
@@ -61,7 +63,7 @@ export interface OwnerRpcResult {
|
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
export interface OwnerRoute {
|
|
64
|
-
method: "GET" | "POST" | "DELETE";
|
|
66
|
+
method: "GET" | "POST" | "DELETE" | "PUT";
|
|
65
67
|
path: string;
|
|
66
68
|
search: string;
|
|
67
69
|
/** Log name. Ids and codes are not included. */
|
|
@@ -133,7 +135,7 @@ function exportQuery(q: Record<string, string>): string | null {
|
|
|
133
135
|
}
|
|
134
136
|
|
|
135
137
|
interface RouteDef {
|
|
136
|
-
method: "GET" | "POST" | "DELETE";
|
|
138
|
+
method: "GET" | "POST" | "DELETE" | "PUT";
|
|
137
139
|
name: string;
|
|
138
140
|
take: (seg: string[]) => boolean;
|
|
139
141
|
search: (q: Record<string, string>) => string | null;
|
|
@@ -157,6 +159,7 @@ const ROUTES: RouteDef[] = [
|
|
|
157
159
|
{ method: "POST", name: "POST /owner/mcp/reply", take: (s) => s.length === 3 && s[1] === "mcp" && s[2] === "reply", search: noQuery, path: () => "/owner/mcp/reply" },
|
|
158
160
|
{ 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" },
|
|
159
161
|
{ method: "GET", name: "GET /owner/outbound-peers", take: (s) => s.length === 2 && s[1] === "outbound-peers", search: noQuery, path: () => "/owner/outbound-peers" },
|
|
162
|
+
{ method: "PUT", name: "PUT /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]}` },
|
|
160
163
|
{ 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]}` },
|
|
161
164
|
{ method: "GET", name: "GET /owner/facade", take: (s) => s.length === 2 && s[1] === "facade", search: noQuery, path: () => "/owner/facade" },
|
|
162
165
|
{ method: "POST", name: "POST /owner/facade/verify", take: (s) => s.length === 3 && s[1] === "facade" && s[2] === "verify", search: noQuery, path: () => "/owner/facade/verify" },
|
|
@@ -169,7 +172,7 @@ const ROUTES: RouteDef[] = [
|
|
|
169
172
|
|
|
170
173
|
/** Method + path (+ query) on the owner RPC allowlist, or why it is not. */
|
|
171
174
|
export function matchOwnerRpc(method: unknown, path: unknown, query: unknown): OwnerMatch {
|
|
172
|
-
if (method !== "GET" && method !== "POST" && method !== "DELETE") return { ok: false, reason: "path" };
|
|
175
|
+
if (method !== "GET" && method !== "POST" && method !== "DELETE" && method !== "PUT") return { ok: false, reason: "path" };
|
|
173
176
|
const seg = segments(path);
|
|
174
177
|
if (!seg) return { ok: false, reason: "path" };
|
|
175
178
|
const def = ROUTES.find((r) => r.method === method && r.take(seg));
|
|
@@ -203,12 +206,34 @@ function mcpArgOk(key: string, value: string): string | null {
|
|
|
203
206
|
return "body is not allowed";
|
|
204
207
|
}
|
|
205
208
|
|
|
209
|
+
const PEER_URL_MAX = 2048;
|
|
210
|
+
const PEER_TOKEN_MAX = 4096;
|
|
211
|
+
|
|
212
|
+
/** `{ url, token }` for a synced outbound peer. `token` may be omitted when the peer takes none. No other keys. */
|
|
213
|
+
function outboundPeerBody(body: unknown): { ok: true; value: Record<string, string> } | { ok: false; error: string } {
|
|
214
|
+
if (body == null || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: "body must be an object" };
|
|
215
|
+
const keys = Object.keys(body);
|
|
216
|
+
if (keys.length > 2 || keys.some((key) => key !== "url" && key !== "token")) return { ok: false, error: "body is not allowed" };
|
|
217
|
+
const url = (body as Record<string, unknown>).url;
|
|
218
|
+
if (typeof url !== "string" || !url.trim()) return { ok: false, error: "url is required" };
|
|
219
|
+
if (url.length > PEER_URL_MAX) return { ok: false, error: "url is invalid" };
|
|
220
|
+
const value: Record<string, string> = { url };
|
|
221
|
+
if (Object.prototype.hasOwnProperty.call(body, "token")) {
|
|
222
|
+
const token = (body as Record<string, unknown>).token;
|
|
223
|
+
if (typeof token !== "string" || !token || token.length > PEER_TOKEN_MAX) return { ok: false, error: "token is invalid" };
|
|
224
|
+
value.token = token;
|
|
225
|
+
}
|
|
226
|
+
return { ok: true, value };
|
|
227
|
+
}
|
|
228
|
+
|
|
206
229
|
/**
|
|
207
230
|
* Body allowed on this owner RPC route.
|
|
208
231
|
* MCP send, reply, and mark_working receive a flat object of tool arguments.
|
|
232
|
+
* PUT /owner/outbound-peers/:alias receives `{ url, token }`. The token is sealed before any log line.
|
|
209
233
|
* Every other route accepts only an absent body.
|
|
210
234
|
*/
|
|
211
235
|
export function ownerRpcBody(routeName: string, body: unknown): { ok: true; value?: Record<string, string> } | { ok: false; error: string } {
|
|
236
|
+
if (routeName === "PUT /owner/outbound-peers/:alias") return outboundPeerBody(body);
|
|
212
237
|
const spec = MCP_ARGS[routeName];
|
|
213
238
|
if (!spec) {
|
|
214
239
|
if (body !== undefined && body !== null) return { ok: false, error: "this call takes no body" };
|
package/worker/src/tenancy.ts
CHANGED
|
@@ -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
|
}
|
package/worker/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Inbox build. Keep this equal to `version` in cli/package.json. */
|
|
2
|
-
export const VERSION = "0.8.
|
|
2
|
+
export const VERSION = "0.8.16";
|
|
@@ -268,6 +268,54 @@ test("proxy mode: JSON-RPC is forwarded with the façade's credential, never the
|
|
|
268
268
|
assert.equal((s.DB.db.prepare("SELECT COUNT(*) AS n FROM tasks").get() as any).n, 0);
|
|
269
269
|
});
|
|
270
270
|
|
|
271
|
+
test("proxy mode: a declared A2A-Version query is forwarded as the header; a headerless call stays headerless", async (t) => {
|
|
272
|
+
let n = 0;
|
|
273
|
+
const s = setup({ UPSTREAM_URL: UPSTREAM, UPSTREAM_CARD_URL: "https://agent-upstream.example.net/card-ver.json" }, async (url, init) => {
|
|
274
|
+
if (url.endsWith(".json")) return Response.json(tailnetCard());
|
|
275
|
+
const rq = JSON.parse(init.body);
|
|
276
|
+
const hv = init.headers?.["a2a-version"];
|
|
277
|
+
const v1 = hv ? hv.startsWith("1") : !String(rq.method).includes("/");
|
|
278
|
+
const id = `up-task-${++n}`, contextId = `up-ctx-${n}`;
|
|
279
|
+
const result = v1
|
|
280
|
+
? { task: { id, contextId, status: { state: "TASK_STATE_SUBMITTED" }, history: [{ role: "ROLE_USER", parts: [{ text: "hi" }] }] } }
|
|
281
|
+
: { kind: "task", id, contextId, status: { state: "submitted" }, history: [{ kind: "message", role: "user", parts: [{ kind: "text", text: "hi" }] }] };
|
|
282
|
+
return Response.json({ jsonrpc: "2.0", id: rq.id, result });
|
|
283
|
+
});
|
|
284
|
+
t.after(s.restore);
|
|
285
|
+
const tok = await s.issue("ada");
|
|
286
|
+
const msg = { message: { role: "user", messageId: "m-ver", parts: [{ kind: "text", text: "hi" }] } };
|
|
287
|
+
const post = (path: string, method: string, headers: Record<string, string> = {}) =>
|
|
288
|
+
s.call("POST", path, { token: tok, json: { jsonrpc: "2.0", id: 1, method, params: msg }, headers });
|
|
289
|
+
const last = () => s.calls.filter((c) => c.url === UPSTREAM).at(-1);
|
|
290
|
+
|
|
291
|
+
const q = await post("/?A2A-Version=0.3", "SendMessage");
|
|
292
|
+
assert.equal(q.status, 200, q.text);
|
|
293
|
+
assert.equal(last().url, UPSTREAM);
|
|
294
|
+
assert.equal(last().headers["a2a-version"], "0.3");
|
|
295
|
+
assert.equal(q.data.result.kind, "task");
|
|
296
|
+
assert.equal(q.data.result.status.state, "submitted");
|
|
297
|
+
assert.equal(q.data.result.task, undefined);
|
|
298
|
+
|
|
299
|
+
const bare = await post("/", "SendMessage");
|
|
300
|
+
assert.equal(last().headers["a2a-version"], undefined);
|
|
301
|
+
assert.equal(bare.data.result.task.status.state, "TASK_STATE_SUBMITTED");
|
|
302
|
+
assert.equal(bare.data.result.kind, undefined);
|
|
303
|
+
|
|
304
|
+
const bare03 = await post("/", "message/send");
|
|
305
|
+
assert.equal(last().headers["a2a-version"], undefined);
|
|
306
|
+
assert.equal(bare03.data.result.kind, "task");
|
|
307
|
+
assert.equal(bare03.data.result.status.state, "submitted");
|
|
308
|
+
|
|
309
|
+
const wins = await post("/?A2A-Version=0.3", "SendMessage", { "a2a-version": "1.0" });
|
|
310
|
+
assert.equal(last().headers["a2a-version"], "1.0");
|
|
311
|
+
assert.equal(wins.data.result.task.status.state, "TASK_STATE_SUBMITTED");
|
|
312
|
+
|
|
313
|
+
const patch = await post("/?A2A-Version=0.3.0", "SendMessage");
|
|
314
|
+
assert.equal(last().headers["a2a-version"], "0.3");
|
|
315
|
+
assert.equal(patch.data.result.kind, "task");
|
|
316
|
+
assert.equal(patch.data.result.status.state, "submitted");
|
|
317
|
+
});
|
|
318
|
+
|
|
271
319
|
test("proxy mode: peers sharing the upstream identity can't touch each other's tasks or contexts", async (t) => {
|
|
272
320
|
const s = setup({ UPSTREAM_URL: UPSTREAM, UPSTREAM_CARD_URL: "https://agent-upstream.example.net/card-c.json" });
|
|
273
321
|
t.after(s.restore);
|
|
@@ -309,7 +357,14 @@ test("proxy mode: peers sharing the upstream identity can't touch each other's t
|
|
|
309
357
|
// ListTasks would list every peer's tasks; streaming is not proxied yet
|
|
310
358
|
assert.equal((await s.rpc(a, "ListTasks", {})).data.error?.code, -32004);
|
|
311
359
|
assert.equal((await s.rpc(a, "SendStreamingMessage", { message: { role: "ROLE_USER", parts: [{ text: "x" }] } })).data.error?.code, -32004);
|
|
312
|
-
|
|
360
|
+
const nCard = s.calls.filter((c) => c.url === UPSTREAM).length;
|
|
361
|
+
const ext = await s.rpc(a, "GetExtendedAgentCard", {});
|
|
362
|
+
assert.equal(ext.status, 200);
|
|
363
|
+
assert.equal(ext.data.error?.code, -32007);
|
|
364
|
+
assert.equal(ext.data.error?.message, "Extended agent card is not configured");
|
|
365
|
+
const ext03 = await s.call("POST", "/", { token: a, json: { jsonrpc: "2.0", id: 1, method: "agent/getAuthenticatedExtendedCard", params: {} }, headers: { "a2a-version": "0.3" } });
|
|
366
|
+
assert.equal(ext03.data.error?.code, -32007);
|
|
367
|
+
assert.equal(s.calls.filter((c) => c.url === UPSTREAM).length, nCard, "the extended card stays on the façade");
|
|
313
368
|
});
|
|
314
369
|
|
|
315
370
|
test("proxy mode: push configs never reach the upstream (it would call them from inside the private network)", async (t) => {
|
|
@@ -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
|
+
});
|