a2a-exposed 0.8.21 → 0.8.23
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/address.mjs +129 -0
- package/lib/commands.mjs +2 -1
- package/lib/main.mjs +17 -0
- package/lib/pair.mjs +5 -0
- package/lib/status.mjs +20 -1
- package/package.json +1 -1
- package/worker/migrations/0014_peer_address.sql +10 -0
- package/worker/migrations/0015_outbound_source.sql +3 -0
- package/worker/src/address.ts +220 -0
- package/worker/src/deadline.ts +22 -0
- package/worker/src/export.ts +2 -1
- package/worker/src/index.ts +576 -69
- package/worker/src/mcp.ts +8 -1
- package/worker/src/migrations.ts +4 -0
- package/worker/src/owner-rpc.ts +45 -1
- package/worker/src/pairing.ts +3 -2
- package/worker/src/version.ts +1 -1
- package/worker/src/wake-target.ts +4 -2
- package/worker/test/address.test.ts +414 -0
- package/worker/test/mcp.test.ts +1 -1
- package/worker/test/migrations.test.ts +21 -4
- package/worker/test/outbound-push.test.ts +190 -0
- package/worker/test/reply-delivery.test.ts +65 -5
- package/worker/test/wake-target.test.ts +10 -0
|
@@ -59,6 +59,10 @@ test("fresh database: every migration applies in order; a second run applies not
|
|
|
59
59
|
assert.deepEqual(applyMigrations(db), [...FILES].sort(byNumber));
|
|
60
60
|
for (const t of ["peers", "tasks", "wakes", "wake_budget", "device_requests", "pairing_rate", "settings", "facade_owners", "oauth_clients", "oauth_codes", "mcp_grants", "outbound_peers", "oauth_client_metadata", "oidc_txns", "conversation_owners", "reply_deliveries"]) assert.ok(tables(db).includes(t), t);
|
|
61
61
|
assert.ok(columns(db, "peers").includes("source"));
|
|
62
|
+
assert.ok(columns(db, "peers").includes("identity_key"));
|
|
63
|
+
assert.ok(columns(db, "peers").includes("card_url"));
|
|
64
|
+
assert.ok(columns(db, "peers").includes("token_enc"));
|
|
65
|
+
assert.ok(columns(db, "device_requests").includes("identity_key"));
|
|
62
66
|
assert.ok(columns(db, "device_requests").includes("replaces_label"));
|
|
63
67
|
assert.ok(columns(db, "device_requests").includes("replaces_hash"));
|
|
64
68
|
assert.deepEqual(applyMigrations(db), []);
|
|
@@ -67,15 +71,15 @@ test("fresh database: every migration applies in order; a second run applies not
|
|
|
67
71
|
test("database from the 0002 fix (0001-0003 recorded): only 0004 and later apply; pending requests keep working", () => {
|
|
68
72
|
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
73
|
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", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql"]);
|
|
74
|
+
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", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql", "0014_peer_address.sql", "0015_outbound_source.sql"]);
|
|
71
75
|
assert.equal((db.prepare("SELECT replaces_label FROM device_requests WHERE device_hash = 'h'").get() as any).replaces_label, null);
|
|
72
76
|
});
|
|
73
77
|
|
|
74
78
|
test("database from v0.2.0 (0001 and 0003 recorded): 0002 applies as a no-op and keeps the data", () => {
|
|
75
79
|
const db = existingDb([sql("0001_init.sql"), sql("0003_device_pairing.sql")], ["0001_init.sql", "0003_device_pairing.sql"]);
|
|
76
80
|
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", "0009_outbound_seen.sql", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.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", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql"]);
|
|
81
|
+
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", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql", "0014_peer_address.sql", "0015_outbound_source.sql"]);
|
|
82
|
+
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", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql", "0014_peer_address.sql", "0015_outbound_source.sql"]);
|
|
79
83
|
assert.equal((db.prepare("SELECT count FROM wake_budget WHERE hour = 1").get() as any).count, 7);
|
|
80
84
|
});
|
|
81
85
|
|
|
@@ -107,7 +111,7 @@ test("database from an earlier build without wake_budget (0001 and 0003 recorded
|
|
|
107
111
|
assert.equal(before.data.error?.code, -32603, JSON.stringify(before.data));
|
|
108
112
|
assert.equal(wakes.length, 0);
|
|
109
113
|
|
|
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", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql"]);
|
|
114
|
+
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", "0010_conversation_peer.sql", "0011_conversation_owner.sql", "0012_conversation_peer_owner.sql", "0013_reply_delivery.sql", "0014_peer_address.sql", "0015_outbound_source.sql"]);
|
|
111
115
|
assert.deepEqual(columns(db, "wake_budget"), ["hour", "count"]);
|
|
112
116
|
assert.deepEqual(applyMigrations(db), []);
|
|
113
117
|
|
|
@@ -169,3 +173,16 @@ test("0012 lets each peer own the same context id and keeps the first row", () =
|
|
|
169
173
|
const peers = db.prepare("SELECT peer FROM conversation_owners WHERE context_id = 'ctx-y' ORDER BY peer").all() as { peer: string }[];
|
|
170
174
|
assert.deepEqual(peers.map((r) => r.peer), ["ada", "bob"]);
|
|
171
175
|
});
|
|
176
|
+
|
|
177
|
+
test("0014 adds address columns and keeps an existing peer token", () => {
|
|
178
|
+
const prior = [...FILES].sort(byNumber).filter((f) => f !== "0014_peer_address.sql");
|
|
179
|
+
const db = existingDb(prior.map(sql), prior);
|
|
180
|
+
db.prepare("INSERT INTO peers (label, token_hash, created_at, source, user_code, client_name) VALUES ('ada', 'hash', '2026-01-01T00:00:00.000Z', 'pairing', 'WDJB4827', 'Ada')").run();
|
|
181
|
+
assert.deepEqual(applyMigrations(db), ["0014_peer_address.sql"]);
|
|
182
|
+
const row = db.prepare("SELECT token_hash, identity_key, card_url, token_enc FROM peers WHERE label = 'ada'").get() as { token_hash: string; identity_key: string | null; card_url: string | null; token_enc: string | null };
|
|
183
|
+
assert.equal(row.token_hash, "hash");
|
|
184
|
+
assert.equal(row.identity_key, null);
|
|
185
|
+
assert.equal(row.card_url, null);
|
|
186
|
+
assert.equal(row.token_enc, null);
|
|
187
|
+
assert.ok(columns(db, "device_requests").includes("identity_key"));
|
|
188
|
+
});
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// A connector send attaches a push config only when the peer card advertises it, and stores the token
|
|
3
3
|
// hash before SendMessage returns. Inbox marks a reply seen only when the stored update is still the
|
|
4
4
|
// snapshot that response returned, on every storage backend this file runs against.
|
|
5
|
+
// Revoking the peer label clears that peer's push hashes. A later push with the old token is 401.
|
|
5
6
|
import { test } from "node:test";
|
|
6
7
|
import assert from "node:assert/strict";
|
|
7
8
|
import { sha256 } from "../src/a2a.ts";
|
|
@@ -340,3 +341,192 @@ test("message and artifactUpdate pushes supply inbox text", async () => {
|
|
|
340
341
|
assert.equal((history.data as Hist[]).some((r) => r.event === "push" && r.text === c.text && r.state === c.state), true, history.text);
|
|
341
342
|
}
|
|
342
343
|
});
|
|
344
|
+
|
|
345
|
+
type OutSnap = {
|
|
346
|
+
task_id?: string;
|
|
347
|
+
context_id?: string;
|
|
348
|
+
peer?: string;
|
|
349
|
+
task_json?: string | null;
|
|
350
|
+
updates_json?: string;
|
|
351
|
+
updates_seen?: number;
|
|
352
|
+
hash_cleared?: number;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
test("revoking a peer rejects its old push token on the self-hosted and hosted paths", async (t) => {
|
|
356
|
+
const logs: string[] = [];
|
|
357
|
+
const origLog = console.log;
|
|
358
|
+
console.log = (...args: unknown[]) => {
|
|
359
|
+
logs.push(args.map((a) => String(a)).join(" "));
|
|
360
|
+
origLog(...args);
|
|
361
|
+
};
|
|
362
|
+
const pushByHost = new Map<string, string>();
|
|
363
|
+
const origFetch = globalThis.fetch;
|
|
364
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
365
|
+
const url = new URL(reqUrl(input));
|
|
366
|
+
if (init?.method !== "POST") {
|
|
367
|
+
return new Response(JSON.stringify(card(`https://${url.host}`, "1.0", true)), { status: 200, headers: { "content-type": "application/json" } });
|
|
368
|
+
}
|
|
369
|
+
const body = JSON.parse(String(init.body)) as { params?: { configuration?: { taskPushNotificationConfig?: { token?: string } } } };
|
|
370
|
+
const token = body.params?.configuration?.taskPushNotificationConfig?.token || "";
|
|
371
|
+
pushByHost.set(url.host, token);
|
|
372
|
+
const taskId = url.host.startsWith("bea.") ? "task-bea" : "task-ada";
|
|
373
|
+
const contextId = url.host.startsWith("bea.") ? "ctx-bea" : "ctx-ada";
|
|
374
|
+
return new Response(JSON.stringify({ jsonrpc: "2.0", id: "1", result: taskResult(taskId, contextId, "1.0") }), { status: 200, headers: { "content-type": "application/json" } });
|
|
375
|
+
}) as typeof fetch;
|
|
376
|
+
t.after(() => {
|
|
377
|
+
globalThis.fetch = origFetch;
|
|
378
|
+
console.log = origLog;
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const secrets: string[] = [];
|
|
382
|
+
const outputs: string[] = [];
|
|
383
|
+
const hides = (blob: string, where: string) => {
|
|
384
|
+
for (const secret of secrets) assert.equal(secret.length > 0 && blob.includes(secret), false, `${where} contains a token`);
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
for (const mode of ["single", "host"] as const) {
|
|
388
|
+
pushByHost.clear();
|
|
389
|
+
const db = d1(MIGRATIONS);
|
|
390
|
+
const publicUrl = "https://owner.example.net";
|
|
391
|
+
const ctx = mode === "single"
|
|
392
|
+
? resolveTenant({ DB: db, PUBLIC_URL: publicUrl, OWNER_TOKEN: OWNER, AGENT_NAME: "Owner" } as WorkerBindings)
|
|
393
|
+
: hostedTenantContext({
|
|
394
|
+
DB: db, TENANCY: "host", TENANT_SECRETS_KEY: "platform-key", PUBLIC_URL: publicUrl, OWNER_TOKEN: "platform-owner",
|
|
395
|
+
} as WorkerBindings, {
|
|
396
|
+
id: "ten_revoke_push", db, publicUrl, ownerTokenHash: await sha256(OWNER), status: "active", config: { AGENT_NAME: "Owner" },
|
|
397
|
+
});
|
|
398
|
+
const http = (method: string, path: string, body?: unknown) => owner(ctx, method, path, body);
|
|
399
|
+
const call = async (method: string, path: string, body?: unknown) => {
|
|
400
|
+
if (mode === "single") return http(method, path, body);
|
|
401
|
+
const res = await runOwnerRpc(ctx, ectx as never, {
|
|
402
|
+
accountId: "acct_owner", method, path, ...(body === undefined ? {} : { body }),
|
|
403
|
+
});
|
|
404
|
+
const text = JSON.stringify(res.body);
|
|
405
|
+
return { status: res.status, data: res.body, text };
|
|
406
|
+
};
|
|
407
|
+
const note = (res: { text: string }) => { outputs.push(res.text); return res; };
|
|
408
|
+
|
|
409
|
+
const issue = async (label: string) => {
|
|
410
|
+
const issued = await http("POST", "/owner/peers", { label });
|
|
411
|
+
assert.equal(issued.status, 200, issued.text);
|
|
412
|
+
const token = (issued.data as { token?: string }).token || "";
|
|
413
|
+
assert.match(token, /^a2aow_/);
|
|
414
|
+
secrets.push(token);
|
|
415
|
+
return token;
|
|
416
|
+
};
|
|
417
|
+
const beaBearer = await issue("bea");
|
|
418
|
+
const adaBearer = await issue("ada");
|
|
419
|
+
for (const [alias, bearer] of [["bea", beaBearer], ["ada", adaBearer]] as const) {
|
|
420
|
+
const synced = note(await call("PUT", `/owner/outbound-peers/${alias}`, { url: `https://${alias}.example.net`, token: bearer }));
|
|
421
|
+
assert.equal(synced.status, 200, synced.text);
|
|
422
|
+
assert.equal(JSON.stringify(synced.data).includes(bearer), false);
|
|
423
|
+
}
|
|
424
|
+
for (const alias of ["bea", "ada"] as const) {
|
|
425
|
+
const sent = note(await call("POST", "/owner/mcp/send", { to: alias, text: `hello ${alias}`, contextId: `ctx-${alias}` }));
|
|
426
|
+
assert.equal(sent.status, 200, sent.text);
|
|
427
|
+
assert.equal((sent.data as SendBody).isError, false, sent.text);
|
|
428
|
+
assert.match((sent.data as SendBody).text || "", new RegExp(`task task-${alias}`));
|
|
429
|
+
}
|
|
430
|
+
const beaPush = pushByHost.get("bea.example.net") || "";
|
|
431
|
+
const adaPush = pushByHost.get("ada.example.net") || "";
|
|
432
|
+
assert.equal(beaPush.length > 0 && adaPush.length > 0 && beaPush !== adaPush, true);
|
|
433
|
+
secrets.push(beaPush, adaPush);
|
|
434
|
+
const pendingToken = "pending-push-token";
|
|
435
|
+
secrets.push(pendingToken);
|
|
436
|
+
await db.prepare(
|
|
437
|
+
`INSERT INTO outbound (task_id, context_id, peer, endpoint, protocol, push_token_hash, sent_at, updates_json, updates_seen)
|
|
438
|
+
VALUES ('pend.revoked', 'ctx-bea', 'bea', 'https://bea.example.net/', '1.0', ?, '2026-01-01T00:00:00.000Z', '[]', 0)`,
|
|
439
|
+
).bind(await sha256(pendingToken)).run();
|
|
440
|
+
|
|
441
|
+
const missed = note(await call("DELETE", "/owner/peers/nobody"));
|
|
442
|
+
assert.equal(missed.status, 404, missed.text);
|
|
443
|
+
const beforeRevoke = await db.prepare("SELECT push_token_hash IS NULL AS cleared FROM outbound WHERE task_id = ?").bind("task-bea").first() as { cleared?: number };
|
|
444
|
+
assert.equal(Number(beforeRevoke.cleared), 0);
|
|
445
|
+
|
|
446
|
+
const revoked = note(await call("DELETE", "/owner/peers/bea"));
|
|
447
|
+
assert.equal(revoked.status, 200, revoked.text);
|
|
448
|
+
assert.deepEqual(revoked.data, { revoked: true, label: "bea" });
|
|
449
|
+
const again = note(await call("DELETE", "/owner/peers/bea"));
|
|
450
|
+
assert.equal(again.status, 404, again.text);
|
|
451
|
+
|
|
452
|
+
const cleared = async (taskId: string) => Number((await db.prepare("SELECT push_token_hash IS NULL AS cleared FROM outbound WHERE task_id = ?").bind(taskId).first() as { cleared?: number }).cleared);
|
|
453
|
+
assert.equal(await cleared("task-bea"), 1);
|
|
454
|
+
assert.equal(await cleared("pend.revoked"), 1);
|
|
455
|
+
assert.equal(await cleared("task-ada"), 0);
|
|
456
|
+
|
|
457
|
+
const snap = async (taskId: string) => await db.prepare(
|
|
458
|
+
`SELECT task_id, context_id, peer, task_json, updates_json, updates_seen, push_token_hash IS NULL AS hash_cleared
|
|
459
|
+
FROM outbound WHERE task_id = ?`,
|
|
460
|
+
).bind(taskId).first() as OutSnap;
|
|
461
|
+
const histCount = async (contextId: string) => Number((await db.prepare("SELECT COUNT(*) AS n FROM history WHERE context_id = ?").bind(contextId).first() as { n: number }).n);
|
|
462
|
+
const taskCount = async () => Number((await db.prepare("SELECT COUNT(*) AS n FROM tasks").first() as { n: number }).n);
|
|
463
|
+
const beaBefore = await snap("task-bea");
|
|
464
|
+
const beaHist = await histCount("ctx-bea");
|
|
465
|
+
const tasksBefore = await taskCount();
|
|
466
|
+
const push = async (token: string, taskId: string, text: string) => {
|
|
467
|
+
const res = await dispatch(new Request("https://owner.example.net/push", {
|
|
468
|
+
method: "POST",
|
|
469
|
+
headers: { "content-type": "application/json", "x-a2a-notification-token": token },
|
|
470
|
+
body: JSON.stringify({ task: { id: taskId, contextId: "ctx-bea", status: { state: "completed", message: { parts: [{ text }] } } } }),
|
|
471
|
+
}), ctx, ectx as never);
|
|
472
|
+
const body = await res.text();
|
|
473
|
+
outputs.push(body);
|
|
474
|
+
return { status: res.status, body };
|
|
475
|
+
};
|
|
476
|
+
const rejected = await push(beaPush, "task-bea", "revoked reply");
|
|
477
|
+
assert.equal(rejected.status, 401);
|
|
478
|
+
assert.equal(rejected.body, '{"error":"unauthorized"}');
|
|
479
|
+
assert.deepEqual(await snap("task-bea"), beaBefore);
|
|
480
|
+
assert.equal(await histCount("ctx-bea"), beaHist);
|
|
481
|
+
assert.equal(await taskCount(), tasksBefore);
|
|
482
|
+
const wake = await db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").bind("ctx-bea").first() as { pending_json?: string | null } | null;
|
|
483
|
+
assert.equal(wake?.pending_json ?? null, null);
|
|
484
|
+
|
|
485
|
+
const pending = await push(pendingToken, "task-claimed", "pending reply");
|
|
486
|
+
assert.equal(pending.status, 401);
|
|
487
|
+
assert.equal(pending.body, '{"error":"unauthorized"}');
|
|
488
|
+
assert.equal(await cleared("pend.revoked"), 1);
|
|
489
|
+
const claimed = await db.prepare("SELECT task_id FROM outbound WHERE task_id = ?").bind("task-claimed").first();
|
|
490
|
+
assert.equal(claimed, null);
|
|
491
|
+
assert.equal(await histCount("ctx-bea"), beaHist);
|
|
492
|
+
assert.deepEqual(await snap("task-bea"), beaBefore);
|
|
493
|
+
|
|
494
|
+
const standin = "restored-push-token";
|
|
495
|
+
secrets.push(standin);
|
|
496
|
+
await db.prepare("UPDATE outbound SET push_token_hash = ? WHERE task_id = ?").bind(await sha256(standin), "task-bea").run();
|
|
497
|
+
const restored = await push(standin, "task-bea", "restored reply");
|
|
498
|
+
assert.equal(restored.status, 401);
|
|
499
|
+
assert.equal(restored.body, '{"error":"unauthorized"}');
|
|
500
|
+
const afterRestore = await snap("task-bea");
|
|
501
|
+
assert.equal(Number(afterRestore.hash_cleared), 0);
|
|
502
|
+
assert.equal(afterRestore.task_json, beaBefore.task_json);
|
|
503
|
+
assert.equal(afterRestore.updates_json, beaBefore.updates_json);
|
|
504
|
+
assert.equal(afterRestore.updates_seen, beaBefore.updates_seen);
|
|
505
|
+
assert.equal(await histCount("ctx-bea"), beaHist);
|
|
506
|
+
|
|
507
|
+
const accepted = await dispatch(new Request("https://owner.example.net/push", {
|
|
508
|
+
method: "POST",
|
|
509
|
+
headers: { "content-type": "application/json", "x-a2a-notification-token": adaPush },
|
|
510
|
+
body: JSON.stringify({ task: { id: "task-ada", contextId: "ctx-ada", status: { state: "completed", message: { parts: [{ text: "ada reply" }] } } } }),
|
|
511
|
+
}), ctx, ectx as never);
|
|
512
|
+
const acceptedText = await accepted.text();
|
|
513
|
+
outputs.push(acceptedText);
|
|
514
|
+
assert.equal(accepted.status, 200, acceptedText);
|
|
515
|
+
const adaRow = await snap("task-ada");
|
|
516
|
+
assert.match(String(adaRow.updates_json), /ada reply/);
|
|
517
|
+
assert.equal(Number(adaRow.hash_cleared), 0);
|
|
518
|
+
const adaHist = note(await call("GET", "/owner/history/ctx-ada"));
|
|
519
|
+
assert.equal((adaHist.data as Hist[]).some((r) => r.event === "push" && r.text === "ada reply"), true, adaHist.text);
|
|
520
|
+
const beaHistory = note(await call("GET", "/owner/history/ctx-bea"));
|
|
521
|
+
assert.equal((beaHistory.data as Hist[]).some((r) => r.text === "revoked reply" || r.text === "pending reply" || r.text === "restored reply"), false, beaHistory.text);
|
|
522
|
+
assert.deepEqual(await snap("task-bea"), afterRestore);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
hides(outputs.join("\n"), "response");
|
|
526
|
+
hides(logs.join("\n"), "log");
|
|
527
|
+
for (const secret of secrets) {
|
|
528
|
+
const hash = await sha256(secret);
|
|
529
|
+
assert.equal(outputs.join("\n").includes(hash), false, "response contains a token hash");
|
|
530
|
+
assert.equal(logs.join("\n").includes(hash), false, "log contains a token hash");
|
|
531
|
+
}
|
|
532
|
+
});
|
|
@@ -20,15 +20,25 @@ async function fixture(t: Parameters<typeof test>[1] extends (t: infer T) => unk
|
|
|
20
20
|
};
|
|
21
21
|
const byId = new Map<string, TenantContext>();
|
|
22
22
|
let directPosts = 0;
|
|
23
|
+
const cards: string[] = [];
|
|
24
|
+
let missingCard = false;
|
|
23
25
|
let hold: Promise<void> | null = null;
|
|
26
|
+
let posted: (() => void) | null = null;
|
|
27
|
+
let aborted = false;
|
|
24
28
|
const ns: DoNamespace = {
|
|
25
29
|
idFromName(name: string) { return name; },
|
|
26
30
|
get(id: unknown) {
|
|
27
31
|
return { async fetch(req: Request) {
|
|
28
32
|
const ctx = byId.get(String(id));
|
|
29
33
|
if (!ctx) return new Response("missing", { status: 404 });
|
|
34
|
+
if (req.method === "GET" && new URL(req.url).pathname.startsWith("/.well-known/agent")) {
|
|
35
|
+
cards.push(new URL(req.url).pathname);
|
|
36
|
+
if (missingCard) return new Response("missing", { status: 404 });
|
|
37
|
+
}
|
|
30
38
|
if (req.method === "POST" && new URL(req.url).pathname === "/") {
|
|
31
39
|
directPosts++;
|
|
40
|
+
posted?.();
|
|
41
|
+
req.signal.addEventListener("abort", () => { aborted = true; }, { once: true });
|
|
32
42
|
if (hold) await hold;
|
|
33
43
|
}
|
|
34
44
|
return dispatch(req, ctx, ectx as never);
|
|
@@ -61,10 +71,11 @@ async function fixture(t: Parameters<typeof test>[1] extends (t: infer T) => unk
|
|
|
61
71
|
assert.equal(r.status, 200, JSON.stringify(data));
|
|
62
72
|
return data.result.id as string;
|
|
63
73
|
};
|
|
64
|
-
return { a, b, dbA, dbB, owner, sendToBob, get directPosts() { return directPosts; }, blockDirect() {
|
|
74
|
+
return { a, b, dbA, dbB, owner, sendToBob, cards, missingCard() { missingCard = true; }, get directPosts() { return directPosts; }, get aborted() { return aborted; }, blockDirect() {
|
|
65
75
|
let release!: () => void;
|
|
66
76
|
hold = new Promise<void>((resolve) => { release = resolve; });
|
|
67
|
-
|
|
77
|
+
const started = new Promise<void>((resolve) => { posted = resolve; });
|
|
78
|
+
return Object.assign(() => { hold = null; posted = null; release(); }, { started });
|
|
68
79
|
} };
|
|
69
80
|
}
|
|
70
81
|
|
|
@@ -129,8 +140,9 @@ test("stale recovery never overlaps an uncertain delivery and a later retry read
|
|
|
129
140
|
const s = await fixture(t as never);
|
|
130
141
|
const taskId = await s.sendToBob("concurrent question", "ctx-concurrent");
|
|
131
142
|
const release = s.blockDirect();
|
|
143
|
+
t.after(release);
|
|
132
144
|
const first = s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "one answer" });
|
|
133
|
-
|
|
145
|
+
await release.started;
|
|
134
146
|
await s.dbB.prepare("UPDATE reply_deliveries SET updated_at = '2000-01-01T00:00:00.000Z'").run();
|
|
135
147
|
const raced = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "one answer" });
|
|
136
148
|
assert.equal(raced.status, 200);
|
|
@@ -138,11 +150,48 @@ test("stale recovery never overlaps an uncertain delivery and a later retry read
|
|
|
138
150
|
assert.match(String((raced.body as any).structuredContent.detail), /uncertain.*not sent again/);
|
|
139
151
|
assert.equal(s.directPosts, 1, "the overlapping call cannot start a second delivery");
|
|
140
152
|
release();
|
|
141
|
-
assert.
|
|
153
|
+
assert.deepEqual(((await first).body as any).structuredContent, (raced.body as any).structuredContent, "a late success cannot replace the recorded uncertain result");
|
|
142
154
|
const retry = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "one answer" });
|
|
143
155
|
assert.equal(retry.status, 200);
|
|
144
|
-
assert.
|
|
156
|
+
assert.deepEqual((retry.body as any).structuredContent, (raced.body as any).structuredContent);
|
|
157
|
+
assert.equal(s.directPosts, 1);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("an identical retry during delivery reports in-flight before the stored completed state", async (t) => {
|
|
161
|
+
const s = await fixture(t as never);
|
|
162
|
+
const taskId = await s.sendToBob("retry question", "ctx-in-flight");
|
|
163
|
+
const release = s.blockDirect();
|
|
164
|
+
t.after(release);
|
|
165
|
+
const first = s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "one answer" });
|
|
166
|
+
await release.started;
|
|
167
|
+
const retry = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "one answer" });
|
|
168
|
+
assert.equal(retry.status, 200);
|
|
169
|
+
assert.equal((retry.body as any).isError, true);
|
|
170
|
+
assert.match(String((retry.body as any).text), /already being delivered/);
|
|
171
|
+
assert.equal(s.directPosts, 1);
|
|
172
|
+
release();
|
|
173
|
+
assert.equal(((await first).body as any).structuredContent.status, "delivered");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("the total delivery deadline returns while a peer ignores cancellation and retries never resend", async (t) => {
|
|
177
|
+
const s = await fixture(t as never);
|
|
178
|
+
const taskId = await s.sendToBob("slow question", "ctx-deadline");
|
|
179
|
+
const release = s.blockDirect();
|
|
180
|
+
t.after(release);
|
|
181
|
+
t.mock.timers.enable({ apis: ["setTimeout"] });
|
|
182
|
+
t.after(() => t.mock.timers.reset());
|
|
183
|
+
const first = s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "bounded answer" });
|
|
184
|
+
await release.started;
|
|
185
|
+
t.mock.timers.tick(18000);
|
|
186
|
+
t.mock.timers.reset();
|
|
187
|
+
const result = await first;
|
|
188
|
+
assert.equal(result.status, 200);
|
|
189
|
+
assert.equal((result.body as any).structuredContent.status, "delivery-failed");
|
|
190
|
+
assert.equal(s.aborted, true, "the deadline reaches the actual peer request signal");
|
|
191
|
+
const retry = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "bounded answer" });
|
|
192
|
+
assert.deepEqual((retry.body as any).structuredContent, (result.body as any).structuredContent);
|
|
145
193
|
assert.equal(s.directPosts, 1);
|
|
194
|
+
release();
|
|
146
195
|
});
|
|
147
196
|
|
|
148
197
|
test("a remote token rejection reports HTTP status without losing the stored reply", async (t) => {
|
|
@@ -159,3 +208,14 @@ test("a remote token rejection reports HTTP status without losing the stored rep
|
|
|
159
208
|
assert.equal(stored.state, "completed");
|
|
160
209
|
assert.match(stored.status_json, /the answer stays/);
|
|
161
210
|
});
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
test("reply discovers one canonical card and uses the paired base when it is missing", async (t) => {
|
|
214
|
+
const s = await fixture(t as never);
|
|
215
|
+
const taskId = await s.sendToBob("no card question", "ctx-one-card");
|
|
216
|
+
s.missingCard();
|
|
217
|
+
const result = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "stored answer" });
|
|
218
|
+
assert.equal((result.body as any).structuredContent.status, "delivered");
|
|
219
|
+
assert.deepEqual(s.cards, ["/.well-known/agent-card.json"]);
|
|
220
|
+
assert.equal(s.directPosts, 1);
|
|
221
|
+
});
|
|
@@ -320,3 +320,13 @@ test("public-https does not request a metadata address, and does post to a check
|
|
|
320
320
|
assert.equal(tls.status, null);
|
|
321
321
|
assert.equal(tls.info, "Error: TLS Handshake Failed.");
|
|
322
322
|
});
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
test("an aborted peer request starts neither DNS lookup nor delivery", async () => {
|
|
326
|
+
const controller = new AbortController();
|
|
327
|
+
controller.abort(new Error("delivery deadline"));
|
|
328
|
+
await assert.rejects(() => fetchCheckedPeer("https://hook.example/rpc", { signal: controller.signal }, {
|
|
329
|
+
async lookup() { assert.fail("aborted request must not resolve DNS"); },
|
|
330
|
+
async deliver() { assert.fail("aborted request must not send"); },
|
|
331
|
+
}), /delivery deadline/);
|
|
332
|
+
});
|