a2a-exposed 0.8.22 → 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 +547 -58
- 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/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
|
+
});
|
|
@@ -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
|
+
});
|