a2a-exposed 0.4.1 → 0.6.0
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/README.md +3 -4
- package/lib/a2a.mjs +6 -2
- package/lib/commands.mjs +62 -12
- package/lib/deploy.mjs +42 -19
- package/lib/main.mjs +32 -11
- package/lib/pair.mjs +47 -3
- package/lib/status.mjs +4 -4
- package/lib/tunnel.mjs +11 -11
- package/lib/update.mjs +1 -1
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +51 -7
- package/worker/deploy.env.example +28 -1
- package/worker/migrations/0006_mcp.sql +43 -0
- package/worker/migrations/0007_cimd.sql +10 -0
- package/worker/migrations/0008_oidc_txns.sql +12 -0
- package/worker/package.json +6 -2
- package/worker/src/approval-oidc.ts +247 -0
- package/worker/src/backup.ts +186 -0
- package/worker/src/directory.ts +120 -0
- package/worker/src/export.ts +156 -0
- package/worker/src/hosted.ts +403 -0
- package/worker/src/index.ts +1327 -446
- package/worker/src/mcp.ts +336 -0
- package/worker/src/migrations.ts +22 -0
- package/worker/src/pairing.ts +18 -5
- package/worker/src/sql-raw.d.ts +5 -0
- package/worker/src/storage.ts +100 -0
- package/worker/src/tenancy.ts +450 -0
- package/worker/src/wake.ts +14 -2
- package/worker/test/alarms.test.ts +231 -0
- package/worker/test/approval-oidc.test.ts +331 -0
- package/worker/test/bridge-thread.mjs +40 -0
- package/worker/test/d1.ts +146 -2
- package/worker/test/directory.test.ts +90 -0
- package/worker/test/export.test.ts +544 -0
- package/worker/test/facade.test.ts +1 -1
- package/worker/test/harness-worker.ts +80 -0
- package/worker/test/hosted.test.ts +221 -0
- package/worker/test/mcp.test.ts +697 -0
- package/worker/test/migrations.test.ts +5 -5
- package/worker/test/pairing.test.ts +20 -7
- package/worker/test/register-hooks.mjs +4 -0
- package/worker/test/sql-raw-hook.mjs +22 -0
- package/worker/test/storage.test.ts +74 -0
- package/worker/test/tenancy.test.ts +217 -0
- package/worker/test/wake-retry.test.ts +74 -0
- package/worker/test/wake.test.ts +18 -0
|
@@ -57,7 +57,7 @@ test("migration files: NNNN_name.sql, unique numbers, no gaps, 0002_wake_budget
|
|
|
57
57
|
test("fresh database: every migration applies in order; a second run applies nothing", () => {
|
|
58
58
|
const db = new DatabaseSync(":memory:");
|
|
59
59
|
assert.deepEqual(applyMigrations(db), [...FILES].sort(byNumber));
|
|
60
|
-
for (const t of ["peers", "tasks", "wakes", "wake_budget", "device_requests", "pairing_rate", "settings", "facade_owners"]) assert.ok(tables(db).includes(t), t);
|
|
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"]) assert.ok(tables(db).includes(t), t);
|
|
61
61
|
assert.ok(columns(db, "peers").includes("source"));
|
|
62
62
|
assert.ok(columns(db, "device_requests").includes("replaces_label"));
|
|
63
63
|
assert.ok(columns(db, "device_requests").includes("replaces_hash"));
|
|
@@ -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"]);
|
|
70
|
+
assert.deepEqual(applyMigrations(db), ["0004_pairing_replace.sql", "0005_facade_owners.sql", "0006_mcp.sql", "0007_cimd.sql", "0008_oidc_txns.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"]);
|
|
78
|
-
assert.deepEqual(recorded(db), ["0001_init.sql", "0003_device_pairing.sql", "0002_wake_budget.sql", "0004_pairing_replace.sql", "0005_facade_owners.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"]);
|
|
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"]);
|
|
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"]);
|
|
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"]);
|
|
111
111
|
assert.deepEqual(columns(db, "wake_budget"), ["hour", "count"]);
|
|
112
112
|
assert.deepEqual(applyMigrations(db), []);
|
|
113
113
|
|
|
@@ -108,7 +108,7 @@ test("agent card, RFC 8414 metadata and the 401 hint advertise the device flow;
|
|
|
108
108
|
const meta = await s.call("GET", "/.well-known/oauth-authorization-server");
|
|
109
109
|
assert.equal(meta.status, 200);
|
|
110
110
|
assert.deepEqual([meta.data.issuer, meta.data.device_authorization_endpoint, meta.data.token_endpoint], [BASE, `${BASE}/oauth/device_authorization`, `${BASE}/oauth/token`]);
|
|
111
|
-
assert.deepEqual(meta.data.grant_types_supported, [GRANT]);
|
|
111
|
+
assert.deepEqual(meta.data.grant_types_supported, [GRANT, "authorization_code", "refresh_token"], "device flow + the MCP connector's grants");
|
|
112
112
|
assert.deepEqual(meta.data.token_endpoint_auth_methods_supported, ["none"]);
|
|
113
113
|
|
|
114
114
|
for (const [method, v] of [["message/send", "0.3"], ["SendMessage", "1.0"]]) {
|
|
@@ -121,6 +121,7 @@ test("agent card, RFC 8414 metadata and the 401 hint advertise the device flow;
|
|
|
121
121
|
assert.equal(r.data.error.data.pairing.device_authorization_endpoint, `${BASE}/oauth/device_authorization`);
|
|
122
122
|
assert.equal(r.data.error.data.pairing.token_endpoint, `${BASE}/oauth/token`);
|
|
123
123
|
assert.equal(r.data.error.data.pairing.grant_type, GRANT);
|
|
124
|
+
assert.equal(r.data.error.data.pairing.cli, `npx -y a2a-exposed@latest connect ${BASE}`, "npx, latest, no global install");
|
|
124
125
|
}
|
|
125
126
|
const bad = await s.call("POST", "/", { json: { jsonrpc: "2.0", id: "req-7", method: "SendMessage", params: {} }, headers: { authorization: "Bearer nope" } });
|
|
126
127
|
assert.equal(bad.headers.get("www-authenticate"), `Bearer realm="a2a", resource_metadata="${BASE}/.well-known/oauth-protected-resource", error="invalid_token", error_description="the bearer token is not valid (revoked, rotated or mistyped)"`);
|
|
@@ -157,7 +158,16 @@ test("/.well-known/agent.json serves an A2A 0.3 card (url, preferredTransport, p
|
|
|
157
158
|
});
|
|
158
159
|
|
|
159
160
|
test("PAIRING_APPROVAL=off: no pairing scheme, endpoints 404, plain 401", async (t) => {
|
|
160
|
-
|
|
161
|
+
// with MCP on (the default) the authorization server stays up for the connector, without the device flow
|
|
162
|
+
const m = setup({ PAIRING_APPROVAL: "off" }); t.after(m.restore);
|
|
163
|
+
const meta = await m.call("GET", "/.well-known/oauth-authorization-server");
|
|
164
|
+
assert.equal(meta.status, 200);
|
|
165
|
+
assert.equal(meta.data.device_authorization_endpoint, undefined);
|
|
166
|
+
assert.deepEqual(meta.data.grant_types_supported, ["authorization_code", "refresh_token"]);
|
|
167
|
+
for (const [mm, p] of [["POST", "/oauth/device_authorization"], ["GET", "/device"]]) assert.equal((await m.call(mm, p, mm === "POST" ? { form: {} } : {})).status, 404, p);
|
|
168
|
+
assert.equal((await m.call("POST", "/oauth/token", { form: { grant_type: GRANT, device_code: "x" } })).data.error, "unsupported_grant_type");
|
|
169
|
+
m.restore();
|
|
170
|
+
const s = setup({ PAIRING_APPROVAL: "off", MCP: "off" }); t.after(s.restore);
|
|
161
171
|
const card = (await s.call("GET", "/.well-known/agent-card.json")).data;
|
|
162
172
|
assert.deepEqual(Object.keys(card.securitySchemes), ["bearer"]);
|
|
163
173
|
assert.deepEqual(card.securityRequirements, [{ schemes: { bearer: { list: [] } } }]);
|
|
@@ -194,7 +204,7 @@ test("human approval: pending, slow_down, page approve with the password, single
|
|
|
194
204
|
assert.equal(s.wakes.length, 1);
|
|
195
205
|
const w = s.wakes[0].body;
|
|
196
206
|
assert.equal(w.kind, "pairing_request");
|
|
197
|
-
assert.equal(w.hint, "npx a2a-exposed pair list");
|
|
207
|
+
assert.equal(w.hint, "npx -y a2a-exposed@latest pair list");
|
|
198
208
|
assert.deepEqual([w.pairing.userCode, w.pairing.verificationUriComplete, w.pairing.approval, w.pairing.clientId], [d.user_code, d.verification_uri_complete, "human", "barry"]);
|
|
199
209
|
assert.equal(w.pairing.agentCardUrl, "https://barry.example.org/.well-known/agent-card.json");
|
|
200
210
|
assert.match(w.pairing.instructions, /never approve on your own[\s\S]*give them this link/);
|
|
@@ -228,7 +238,7 @@ test("human approval: pending, slow_down, page approve with the password, single
|
|
|
228
238
|
assert.equal(g.headers.get("x-frame-options"), "DENY");
|
|
229
239
|
assert.match(g.headers.get("set-cookie")!, /^a2a_device_csrf=[A-Za-z0-9_-]{24}; Path=\/device; Secure; HttpOnly; SameSite=Strict/);
|
|
230
240
|
assert.ok(!/<script|src=|href=/i.test(g.text), "no scripts or external assets");
|
|
231
|
-
assert.match(g.text, /No approval password is set yet[\s\S]*npx a2a-exposed pair set-password/);
|
|
241
|
+
assert.match(g.text, /No approval password is set yet[\s\S]*npx -y a2a-exposed@latest pair set-password/);
|
|
232
242
|
assert.ok(!g.text.includes('name="password"'));
|
|
233
243
|
assert.equal((await s.decidePage(d.user_code, PASSWORD, "approve")).status, 409);
|
|
234
244
|
|
|
@@ -362,7 +372,7 @@ test("flood control: requests per IP and outstanding requests are capped (no flo
|
|
|
362
372
|
test("pairing wakes: summary for each preset; requester text only where previews are allowed", async () => {
|
|
363
373
|
const ev: WakeEvent = { contextId: "pairing", taskId: "none", taskIds: [], from: "Barry", preview: "", kind: "pairing_request", publicUrl: BASE,
|
|
364
374
|
pairing: { userCode: "WDJB-2345", verificationUriComplete: `${BASE}/device?user_code=WDJB-2345`, approval: "human", clientName: "Barry", clientId: "barry", agentCardUrl: "https://barry.example.org/card", expiresIn: 600 } };
|
|
365
|
-
const sum = wakeSummary(ev, "npx a2a-exposed");
|
|
375
|
+
const sum = wakeSummary(ev, "npx -y a2a-exposed@latest");
|
|
366
376
|
assert.match(sum, /^A2A pairing request: an agent calling itself "Barry" \(claimed card: "https:\/\/barry\.example\.org\/card"\) asks to connect to your inbox \(code WDJB-2345, expires in 10 minutes\)\./);
|
|
367
377
|
assert.match(sum, /never approve on your own\. Show them the code WDJB-2345 and give them this link: https:\/\/agent\.example\.com\/device\?user_code=WDJB-2345/);
|
|
368
378
|
const agentSum = wakeSummary({ ...ev, pairing: { ...ev.pairing!, approval: "agent" } }, "a2a");
|
|
@@ -581,8 +591,11 @@ test("password setup link: expiry, mismatch, too short, CSRF, a new link invalid
|
|
|
581
591
|
for (let i = 0; i < 20; i++) await s.call("GET", "/device/setup?t=nope", { ip: "192.0.2.200" });
|
|
582
592
|
const limited = await s.call("GET", new URL(l.url).pathname + new URL(l.url).search, { ip: "192.0.2.200" });
|
|
583
593
|
assert.equal(limited.status, 429);
|
|
584
|
-
// off
|
|
594
|
+
// pairing off: the link still works while MCP is on (the connector consent needs the password), not with both off
|
|
585
595
|
s.env.PAIRING_APPROVAL = "off";
|
|
596
|
+
assert.equal((await s.owner("POST", "/owner/pairing/password-link", {})).status, 200);
|
|
597
|
+
assert.equal((await s.call("GET", "/device/setup?t=x", { ip: "192.0.2.201" })).status, 404, "reachable (unknown setup token)");
|
|
598
|
+
s.env.MCP = "off";
|
|
586
599
|
assert.equal((await s.owner("POST", "/owner/pairing/password-link", {})).status, 409);
|
|
587
600
|
assert.equal((await s.call("GET", "/device/setup?t=x")).status, 404);
|
|
588
601
|
});
|
|
@@ -621,7 +634,7 @@ test("GET /: a browser gets an HTML landing page (strict CSP, no scripts); curl/
|
|
|
621
634
|
assert.match(h.text, /A2A \(Agent2Agent\)<\/a> endpoint/);
|
|
622
635
|
assert.ok(h.text.includes(`href="/.well-known/agent-card.json"`) && h.text.includes(`${BASE}/.well-known/agent-card.json`));
|
|
623
636
|
assert.ok(h.text.includes(`href="/device"`), "pairing link");
|
|
624
|
-
assert.ok(h.text.includes(`npx a2a-exposed connect ${BASE}`));
|
|
637
|
+
assert.ok(h.text.includes(`npx -y a2a-exposed@latest connect ${BASE}`));
|
|
625
638
|
assert.match(h.text, /1\.0, 0\.3/);
|
|
626
639
|
// the style block carries the CSP nonce
|
|
627
640
|
const nonce = /style-src 'nonce-([^']+)'/.exec(csp)![1];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Resolve `../migrations/0001_init.sql?raw` to the file text as a default export.
|
|
2
|
+
export async function resolve(specifier, context, nextResolve) {
|
|
3
|
+
if (typeof specifier === "string" && specifier.includes(".sql?raw")) {
|
|
4
|
+
const resolved = await nextResolve(specifier.replace(/\?raw$/, ""), context);
|
|
5
|
+
const url = new URL(resolved.url);
|
|
6
|
+
url.searchParams.set("raw", "1");
|
|
7
|
+
return { url: url.href, shortCircuit: true };
|
|
8
|
+
}
|
|
9
|
+
return nextResolve(specifier, context);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function load(url, context, nextLoad) {
|
|
13
|
+
const u = new URL(url);
|
|
14
|
+
if (u.searchParams.get("raw") === "1" && u.pathname.endsWith(".sql")) {
|
|
15
|
+
const { readFileSync } = await import("node:fs");
|
|
16
|
+
const { fileURLToPath } = await import("node:url");
|
|
17
|
+
u.search = "";
|
|
18
|
+
const text = readFileSync(fileURLToPath(u), "utf8");
|
|
19
|
+
return { format: "module", source: `export default ${JSON.stringify(text)};\n`, shortCircuit: true };
|
|
20
|
+
}
|
|
21
|
+
return nextLoad(url, context);
|
|
22
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Adapter and migration runner over node:sqlite. The workerd backends are STORAGE_BACKEND=d1|do. Run: npm test
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { DatabaseSync } from "node:sqlite";
|
|
6
|
+
import { doSqlD1, migrateDo, type SqlStorageLike, type TxRunner } from "../src/storage.ts";
|
|
7
|
+
import { MIGRATIONS } from "../src/migrations.ts";
|
|
8
|
+
|
|
9
|
+
function asSql(db: DatabaseSync): SqlStorageLike {
|
|
10
|
+
return {
|
|
11
|
+
exec(query: string, ...bindings: unknown[]) {
|
|
12
|
+
const multi = query.includes(";") && !/^\s*select\b/i.test(query);
|
|
13
|
+
if (bindings.length === 0 && multi) {
|
|
14
|
+
db.exec(query);
|
|
15
|
+
return { toArray: () => [], raw: () => [], rowsRead: 0, rowsWritten: 0 };
|
|
16
|
+
}
|
|
17
|
+
const stmt = db.prepare(query);
|
|
18
|
+
if (/^\s*select\b/i.test(query)) {
|
|
19
|
+
const rows = stmt.all(...(bindings as [])) as Record<string, unknown>[];
|
|
20
|
+
return { toArray: () => rows, raw: () => rows.map((r) => Object.values(r)), rowsRead: rows.length, rowsWritten: 0 };
|
|
21
|
+
}
|
|
22
|
+
const info = stmt.run(...(bindings as []));
|
|
23
|
+
return { toArray: () => [], raw: () => [], rowsRead: 0, rowsWritten: Number(info.changes) };
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function txOf(db: DatabaseSync): TxRunner {
|
|
29
|
+
return (fn) => {
|
|
30
|
+
db.exec("BEGIN");
|
|
31
|
+
try { const v = fn(); db.exec("COMMIT"); return v; }
|
|
32
|
+
catch (e) { db.exec("ROLLBACK"); throw e; }
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("bundled migrations match worker/migrations", () => {
|
|
37
|
+
const disk = fs.readdirSync(new URL("../migrations/", import.meta.url)).filter((n) => n.endsWith(".sql")).sort();
|
|
38
|
+
assert.deepEqual(MIGRATIONS.map((m) => m.name), disk);
|
|
39
|
+
for (const m of MIGRATIONS) {
|
|
40
|
+
const file = fs.readFileSync(new URL(`../migrations/${m.name}`, import.meta.url), "utf8");
|
|
41
|
+
assert.equal(m.sql, file, m.name);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("migrateDo applies in numeric order and a later lower number still runs", () => {
|
|
46
|
+
const db = new DatabaseSync(":memory:");
|
|
47
|
+
const sql = asSql(db);
|
|
48
|
+
const tx = txOf(db);
|
|
49
|
+
const early = MIGRATIONS.filter((m) => m.name === "0001_init.sql" || m.name === "0003_device_pairing.sql");
|
|
50
|
+
assert.deepEqual(migrateDo(sql, tx, early), ["0001_init.sql", "0003_device_pairing.sql"]);
|
|
51
|
+
const rest = migrateDo(sql, tx, MIGRATIONS);
|
|
52
|
+
assert.equal(rest[0], "0002_wake_budget.sql");
|
|
53
|
+
assert.deepEqual(rest, MIGRATIONS.map((m) => m.name).filter((n) => n !== "0001_init.sql" && n !== "0003_device_pairing.sql"));
|
|
54
|
+
assert.deepEqual(migrateDo(sql, tx, MIGRATIONS), []);
|
|
55
|
+
const names = db.prepare("SELECT name FROM d1_migrations ORDER BY id").all().map((r) => (r as { name: string }).name);
|
|
56
|
+
assert.deepEqual(names, ["0001_init.sql", "0003_device_pairing.sql", ...rest]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("doSqlD1 matches D1 binding rules: booleans, undefined, missing columns, batch rollback", async () => {
|
|
60
|
+
const db = new DatabaseSync(":memory:");
|
|
61
|
+
const d = doSqlD1(asSql(db), txOf(db));
|
|
62
|
+
await d.exec("CREATE TABLE t (n INTEGER)");
|
|
63
|
+
await d.prepare("INSERT INTO t (n) VALUES (?)").bind(true).run();
|
|
64
|
+
assert.equal((await d.prepare("SELECT n FROM t").first<{ n: number }>())?.n, 1);
|
|
65
|
+
const wrote = await d.prepare("INSERT INTO t (n) VALUES (?)").bind(4).run();
|
|
66
|
+
assert.equal(wrote.meta.changes, 1);
|
|
67
|
+
await assert.rejects(() => d.prepare("SELECT n FROM t WHERE n = ?").bind(undefined).first(), /D1_TYPE_ERROR/);
|
|
68
|
+
await assert.rejects(() => d.prepare("SELECT n FROM t").first("missing"), /D1_COLUMN_NOTFOUND/);
|
|
69
|
+
await assert.rejects(() => d.batch([
|
|
70
|
+
d.prepare("INSERT INTO t (n) VALUES (9)"),
|
|
71
|
+
d.prepare("INSERT INTO missing (n) VALUES (1)"),
|
|
72
|
+
]));
|
|
73
|
+
assert.equal((await d.prepare("SELECT COUNT(*) AS c FROM t WHERE n = 9").first<{ c: number }>())?.c, 0);
|
|
74
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Tenant context: gates default off, self-host owner auth and peer-token key stay compatible, hosted uses a hash
|
|
2
|
+
// and TENANT_SECRETS_KEY. Run: npm test
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import worker from "../src/index.ts";
|
|
6
|
+
import { sha256 } from "../src/a2a.ts";
|
|
7
|
+
import { openPeerToken, openPeerTokenWithKey, sealPeerToken, sealPeerTokenWithKey } from "../src/mcp.ts";
|
|
8
|
+
import { hostedTenantContext, namespaceForRegion, parseGates, resolveTenant, type WorkerBindings } from "../src/tenancy.ts";
|
|
9
|
+
import { d1 } from "./d1.ts";
|
|
10
|
+
|
|
11
|
+
const dummy = { prepare() { throw new Error("storage should not be touched"); } } as any;
|
|
12
|
+
const BASE = "https://agent.example.com";
|
|
13
|
+
|
|
14
|
+
function selfEnv(over: Record<string, unknown> = {}): WorkerBindings {
|
|
15
|
+
return { DB: dummy, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", ...over } as WorkerBindings;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("gates: unset is single-tenant and every reserved gate is off", () => {
|
|
19
|
+
const g = parseGates({ DB: dummy } as WorkerBindings).gates;
|
|
20
|
+
assert.equal(g.tenancy, "single");
|
|
21
|
+
assert.equal(g.hostedStorage, false);
|
|
22
|
+
assert.equal(g.quotas, false);
|
|
23
|
+
assert.equal(g.usageSink, "");
|
|
24
|
+
assert.equal(g.wakeTargetPolicy, "");
|
|
25
|
+
assert.equal(g.signupUrl, "");
|
|
26
|
+
assert.equal(g.branding, "");
|
|
27
|
+
assert.equal(g.dataRegion, "");
|
|
28
|
+
assert.equal(g.tenantDomain, "");
|
|
29
|
+
assert.deepEqual(g.approvalOidc, { issuer: "", clientId: "", hasClientSecret: false, allowedSubjects: [], methods: ["password"], methodsSpecified: false, label: "" });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("gates: only exact values turn on; typos and non-https URLs stay off", () => {
|
|
33
|
+
const g = parseGates({
|
|
34
|
+
DB: dummy, TENANCY: "Host", QUOTAS: "yes", USAGE_SINK: "log", WAKE_TARGET_POLICY: "https",
|
|
35
|
+
BRANDING: "a2a", DATA_REGION: "us", SIGNUP_URL: "http://example.com", APPROVAL_OIDC_ISSUER: "http://example.com",
|
|
36
|
+
APPROVAL_METHODS: "magic", TENANT_DOMAIN: ".Example.COM.",
|
|
37
|
+
} as WorkerBindings).gates;
|
|
38
|
+
assert.equal(g.tenancy, "single");
|
|
39
|
+
assert.equal(g.quotas, false);
|
|
40
|
+
assert.equal(g.usageSink, "");
|
|
41
|
+
assert.equal(g.wakeTargetPolicy, "");
|
|
42
|
+
assert.equal(g.branding, "");
|
|
43
|
+
assert.equal(g.dataRegion, "");
|
|
44
|
+
assert.equal(g.signupUrl, "");
|
|
45
|
+
assert.equal(g.approvalOidc.issuer, "");
|
|
46
|
+
assert.deepEqual(g.approvalOidc.methods, ["password"]);
|
|
47
|
+
assert.equal(g.approvalOidc.methodsSpecified, false);
|
|
48
|
+
assert.equal(g.tenantDomain, "example.com");
|
|
49
|
+
|
|
50
|
+
const on = parseGates({
|
|
51
|
+
DB: dummy, TENANCY: "host", TENANT_DO: {} as any, QUOTAS: "on", USAGE_SINK: "d1",
|
|
52
|
+
WAKE_TARGET_POLICY: "public-https", BRANDING: "hosted", DATA_REGION: "EU",
|
|
53
|
+
SIGNUP_URL: "https://example.com/start", APPROVAL_OIDC_ISSUER: "https://example.com",
|
|
54
|
+
APPROVAL_OIDC_CLIENT_ID: "client", APPROVAL_OIDC_CLIENT_SECRET: "secret",
|
|
55
|
+
APPROVAL_OIDC_ALLOWED_SUBJECTS: " acct_1 , acct_2 ", APPROVAL_METHODS: "oidc,password",
|
|
56
|
+
TENANT_DOMAIN: "example.com",
|
|
57
|
+
} as WorkerBindings).gates;
|
|
58
|
+
assert.equal(on.tenancy, "host");
|
|
59
|
+
assert.equal(on.hostedStorage, true);
|
|
60
|
+
assert.equal(on.quotas, true);
|
|
61
|
+
assert.equal(on.usageSink, "d1");
|
|
62
|
+
assert.equal(on.wakeTargetPolicy, "public-https");
|
|
63
|
+
assert.equal(on.branding, "hosted");
|
|
64
|
+
assert.equal(on.dataRegion, "eu");
|
|
65
|
+
assert.equal(on.signupUrl, "https://example.com/start");
|
|
66
|
+
assert.equal(on.approvalOidc.issuer, "https://example.com");
|
|
67
|
+
assert.equal(on.approvalOidc.hasClientSecret, true);
|
|
68
|
+
assert.deepEqual(on.approvalOidc.allowedSubjects, ["acct_1", "acct_2"]);
|
|
69
|
+
assert.deepEqual(on.approvalOidc.methods, ["oidc", "password"]);
|
|
70
|
+
assert.equal(on.approvalOidc.methodsSpecified, true);
|
|
71
|
+
assert.equal(on.approvalOidc.label, "");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("TENANCY=host without TENANT_DO does not flip hostedStorage", () => {
|
|
75
|
+
assert.equal(parseGates({ DB: dummy, TENANCY: "host" } as WorkerBindings).gates.hostedStorage, false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("self-host peer key decrypts tokens sealed from OWNER_TOKEN, and ignores TENANT_SECRETS_KEY", async () => {
|
|
79
|
+
const sealed = await sealPeerToken("owner-secret", "alpha", "peer-token");
|
|
80
|
+
const ctx = resolveTenant(selfEnv({ TENANT_SECRETS_KEY: "platform-key-not-used", TENANCY: "host" }));
|
|
81
|
+
assert.equal(ctx.mode, "single");
|
|
82
|
+
assert.equal(ctx.id, "");
|
|
83
|
+
const key = await ctx.peerKey();
|
|
84
|
+
assert.ok(key);
|
|
85
|
+
assert.equal(await openPeerTokenWithKey(key!, "alpha", sealed), "peer-token");
|
|
86
|
+
assert.equal(await openPeerToken("owner-secret", "alpha", sealed), "peer-token");
|
|
87
|
+
assert.equal(await ctx.peerKey(), key, "the key is derived once per context");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("self-host owner check is OWNER_TOKEN equality and rejects a missing token before accepting anyone", async () => {
|
|
91
|
+
const ctx = resolveTenant(selfEnv());
|
|
92
|
+
assert.equal(await ctx.isOwner("Bearer owner-secret"), true);
|
|
93
|
+
assert.equal(await ctx.isOwner("bearer owner-secret"), true);
|
|
94
|
+
assert.equal(await ctx.isOwner("Bearer owner-secret "), true);
|
|
95
|
+
assert.equal(await ctx.isOwner("Bearer other"), false);
|
|
96
|
+
assert.equal(await ctx.isOwner(null), false);
|
|
97
|
+
assert.equal(await ctx.isOwner("Basic owner-secret"), false);
|
|
98
|
+
const none = resolveTenant(selfEnv({ OWNER_TOKEN: undefined }));
|
|
99
|
+
assert.equal(await none.isOwner("Bearer owner-secret"), false);
|
|
100
|
+
assert.equal(await none.peerKey(), null);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("hosted owner check uses the token hash and never the platform OWNER_TOKEN", async () => {
|
|
104
|
+
const token = "hosted-owner-token";
|
|
105
|
+
const hash = await sha256(token);
|
|
106
|
+
const ctx = hostedTenantContext(selfEnv({ TENANT_SECRETS_KEY: "platform-key" }), {
|
|
107
|
+
id: "ten_a", db: dummy, publicUrl: "https://a.example.com", ownerTokenHash: hash.toUpperCase(),
|
|
108
|
+
});
|
|
109
|
+
assert.equal(ctx.mode, "host");
|
|
110
|
+
assert.equal(await ctx.isOwner(`Bearer ${token}`), true);
|
|
111
|
+
assert.equal(await ctx.isOwner("Bearer owner-secret"), false, "platform OWNER_TOKEN does not authenticate");
|
|
112
|
+
assert.equal(await ctx.isOwner("Bearer wrong"), false);
|
|
113
|
+
const bad = hostedTenantContext(selfEnv(), {
|
|
114
|
+
id: "ten_a", db: dummy, publicUrl: "https://a.example.com", ownerTokenHash: "not-a-hash",
|
|
115
|
+
});
|
|
116
|
+
assert.equal(await bad.isOwner(`Bearer ${token}`), false);
|
|
117
|
+
const missing = hostedTenantContext(selfEnv(), { id: "ten_a", db: dummy, publicUrl: "https://a.example.com" });
|
|
118
|
+
assert.equal(await missing.isOwner(`Bearer ${token}`), false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("hosted peer key is HKDF(TENANT_SECRETS_KEY, tenant id) and does not open another tenant or a self-host seal", async () => {
|
|
122
|
+
const env = selfEnv({ TENANT_SECRETS_KEY: "platform-key" });
|
|
123
|
+
const a = hostedTenantContext(env, { id: "ten_a", db: dummy, publicUrl: "https://a.example.com", ownerTokenHash: "ab".repeat(32) });
|
|
124
|
+
const b = hostedTenantContext(env, { id: "ten_b", db: dummy, publicUrl: "https://b.example.com", ownerTokenHash: "ab".repeat(32) });
|
|
125
|
+
const keyA = await a.peerKey();
|
|
126
|
+
assert.ok(keyA);
|
|
127
|
+
const sealed = await sealPeerTokenWithKey(keyA!, "alpha", "peer-token");
|
|
128
|
+
assert.equal(await openPeerTokenWithKey(keyA!, "alpha", sealed), "peer-token");
|
|
129
|
+
assert.equal(await openPeerTokenWithKey((await b.peerKey())!, "alpha", sealed), null);
|
|
130
|
+
assert.equal(await openPeerToken("owner-secret", "alpha", sealed), null);
|
|
131
|
+
assert.equal(await openPeerToken("platform-key", "alpha", sealed), null);
|
|
132
|
+
const selfSealed = await sealPeerToken("owner-secret", "alpha", "peer-token");
|
|
133
|
+
assert.equal(await openPeerTokenWithKey(keyA!, "alpha", selfSealed), null);
|
|
134
|
+
const noKey = hostedTenantContext(selfEnv(), { id: "ten_a", db: dummy, publicUrl: "https://a.example.com" });
|
|
135
|
+
assert.equal(await noKey.peerKey(), null);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("hosted config does not inherit Worker secrets; operational caps do inherit and can be overridden", () => {
|
|
139
|
+
const ctx = hostedTenantContext(selfEnv({
|
|
140
|
+
WAKE_WEBHOOK_URL: "https://hooks.example.net/wake", WAKE_WEBHOOK_KEY: "hook-key",
|
|
141
|
+
UPSTREAM_TOKEN: "upstream", RATE_PER_MIN: "60", AGENT_NAME: "Platform name",
|
|
142
|
+
}), {
|
|
143
|
+
id: "ten_a", db: dummy, publicUrl: "https://a.example.com",
|
|
144
|
+
config: { AGENT_NAME: "Tenant A", WAKE_WEBHOOK_URL: "https://hooks.example.net/a" },
|
|
145
|
+
});
|
|
146
|
+
assert.equal(ctx.AGENT_NAME, "Tenant A");
|
|
147
|
+
assert.equal(ctx.PUBLIC_URL, "https://a.example.com");
|
|
148
|
+
assert.equal(ctx.WAKE_WEBHOOK_URL, "https://hooks.example.net/a");
|
|
149
|
+
assert.equal(ctx.WAKE_WEBHOOK_KEY, undefined);
|
|
150
|
+
assert.equal(ctx.UPSTREAM_TOKEN, undefined);
|
|
151
|
+
assert.equal(ctx.RATE_PER_MIN, "60");
|
|
152
|
+
const capped = hostedTenantContext(selfEnv({ RATE_PER_MIN: "60" }), {
|
|
153
|
+
id: "ten_a", db: dummy, publicUrl: "https://a.example.com", config: { RATE_PER_MIN: "10" },
|
|
154
|
+
});
|
|
155
|
+
assert.equal(capped.RATE_PER_MIN, "10");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("DATA_REGION selects a DO jurisdiction only for eu and fedramp", () => {
|
|
159
|
+
const calls: string[] = [];
|
|
160
|
+
const ns = { id: "default", jurisdiction(loc: string) { calls.push(loc); return { id: loc, jurisdiction: ns.jurisdiction }; } };
|
|
161
|
+
assert.equal(namespaceForRegion(ns, "").id, "default");
|
|
162
|
+
assert.equal(namespaceForRegion(ns, "eu").id, "eu");
|
|
163
|
+
assert.equal(namespaceForRegion(ns, "fedramp").id, "fedramp");
|
|
164
|
+
assert.equal(namespaceForRegion(ns, "us").id, "default");
|
|
165
|
+
assert.deepEqual(calls, ["eu", "fedramp"]);
|
|
166
|
+
assert.equal(namespaceForRegion({ id: "plain" }, "eu").id, "plain");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("hosted approval comes from the tenant overlay and ignores Worker APPROVAL_OIDC_*", () => {
|
|
170
|
+
const platform = selfEnv({
|
|
171
|
+
APPROVAL_OIDC_ISSUER: "https://platform.example", APPROVAL_OIDC_CLIENT_ID: "platform",
|
|
172
|
+
APPROVAL_OIDC_CLIENT_SECRET: "platform-secret", APPROVAL_OIDC_ALLOWED_SUBJECTS: "platform-user",
|
|
173
|
+
APPROVAL_METHODS: "oidc",
|
|
174
|
+
});
|
|
175
|
+
const ctx = hostedTenantContext(platform, {
|
|
176
|
+
id: "ten_a", db: dummy, publicUrl: "https://a.example.com",
|
|
177
|
+
approval: { issuer: "https://idp.example", clientId: "tenant-client", clientSecret: "tenant-secret", allowedSubjects: ["owner-1"], label: "Example IdP" },
|
|
178
|
+
});
|
|
179
|
+
assert.equal(ctx.gates.approvalOidc.issuer, "https://idp.example");
|
|
180
|
+
assert.equal(ctx.gates.approvalOidc.clientId, "tenant-client");
|
|
181
|
+
assert.deepEqual(ctx.gates.approvalOidc.allowedSubjects, ["owner-1"]);
|
|
182
|
+
assert.equal(ctx.gates.approvalOidc.label, "Example IdP");
|
|
183
|
+
assert.equal(ctx.gates.approvalOidc.hasClientSecret, true);
|
|
184
|
+
assert.equal(ctx.approvalClientSecret(), "tenant-secret");
|
|
185
|
+
assert.equal(JSON.stringify(ctx.approval).includes("tenant-secret"), false);
|
|
186
|
+
assert.equal(ctx.gates.approvalOidc.methodsSpecified, false, "omitted methods stay unspecified so both password and OIDC are offered");
|
|
187
|
+
const empty = hostedTenantContext(platform, { id: "ten_a", db: dummy, publicUrl: "https://a.example.com" });
|
|
188
|
+
assert.equal(empty.gates.approvalOidc.issuer, "");
|
|
189
|
+
assert.equal(empty.approvalClientSecret(), "");
|
|
190
|
+
assert.equal(empty.gates.approvalOidc.hasClientSecret, false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("fetch path: gates set or unset, the self-host Worker still answers from OWNER_TOKEN and env.DB", async () => {
|
|
194
|
+
const DB = d1(new URL("../migrations/", import.meta.url));
|
|
195
|
+
const base = { DB, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", AGENT_NAME: "Test Inbox" };
|
|
196
|
+
const gates = {
|
|
197
|
+
TENANCY: "host", TENANT_SECRETS_KEY: "platform-key", TENANT_DOMAIN: "example.com", DATA_REGION: "eu",
|
|
198
|
+
QUOTAS: "on", USAGE_SINK: "d1", WAKE_TARGET_POLICY: "public-https", SIGNUP_URL: "https://example.com",
|
|
199
|
+
BRANDING: "hosted", APPROVAL_OIDC_ISSUER: "https://example.com", APPROVAL_OIDC_CLIENT_ID: "client",
|
|
200
|
+
APPROVAL_OIDC_CLIENT_SECRET: "secret", APPROVAL_OIDC_ALLOWED_SUBJECTS: "acct_1", APPROVAL_METHODS: "oidc",
|
|
201
|
+
};
|
|
202
|
+
const call = async (env: Record<string, unknown>, path: string, headers: Record<string, string> = {}) => {
|
|
203
|
+
const res = await worker.fetch(new Request(BASE + path, { headers }) as any, env as any, { waitUntil() {}, passThroughOnException() {} } as any);
|
|
204
|
+
return { status: res.status, data: await res.json() as any };
|
|
205
|
+
};
|
|
206
|
+
const plain = await call(base, "/health");
|
|
207
|
+
const reserved = await call({ ...base, ...gates }, "/health");
|
|
208
|
+
const empty = await call({ ...base, TENANCY: "", QUOTAS: "", DATA_REGION: "", SIGNUP_URL: "" }, "/health");
|
|
209
|
+
assert.deepEqual(plain.data, { ok: true, publicUrl: BASE, mode: "inbox", mcp: true });
|
|
210
|
+
assert.deepEqual(reserved.data, plain.data);
|
|
211
|
+
assert.deepEqual(empty.data, plain.data);
|
|
212
|
+
|
|
213
|
+
const own = await call({ ...base, ...gates }, "/owner/inbox", { authorization: "Bearer owner-secret" });
|
|
214
|
+
assert.equal(own.status, 200, JSON.stringify(own.data));
|
|
215
|
+
const no = await call({ ...base, ...gates }, "/owner/inbox", { authorization: "Bearer platform-key" });
|
|
216
|
+
assert.equal(no.status, 401);
|
|
217
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// A wake endpoint that answers 429 / 503 (e.g. a Claude Code routine over its fire limit): the wake is kept pending and
|
|
2
|
+
// re-sent after Retry-After, at most 3 times. Run: npm test
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import worker, { nextCronAt } from "../src/index.ts";
|
|
6
|
+
import { d1 } from "./d1.ts";
|
|
7
|
+
import { resolveTenant } from "../src/tenancy.ts";
|
|
8
|
+
|
|
9
|
+
const BASE = "https://agent.example.com";
|
|
10
|
+
const realNow = Date.now;
|
|
11
|
+
let offset = 0;
|
|
12
|
+
Date.now = () => realNow() + offset;
|
|
13
|
+
|
|
14
|
+
test("429 with Retry-After: the wake is re-sent after the wait, merged with newer tasks, and dropped after 3 retries", async (t) => {
|
|
15
|
+
const env: any = { DB: d1(new URL("../migrations/", import.meta.url)), PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", WAKE_PRESET: "claude-code",
|
|
16
|
+
WAKE_WEBHOOK_URL: "https://api.anthropic.com/v1/claude_code/routines/trig_x/fire", WAKE_WEBHOOK_KEY: "rt", WAKE_DEBOUNCE_SECONDS: "20" };
|
|
17
|
+
const sent: any[] = [];
|
|
18
|
+
let answer = 429;
|
|
19
|
+
const orig = globalThis.fetch;
|
|
20
|
+
globalThis.fetch = (async (_u: any, init: any) => { sent.push(JSON.parse(init.body)); return new Response("{}", { status: answer, headers: { "retry-after": "120" } }); }) as any;
|
|
21
|
+
t.after(() => { globalThis.fetch = orig; Date.now = realNow; });
|
|
22
|
+
const call = async (method: string, path: string, json?: any, auth = "Bearer owner-secret") => {
|
|
23
|
+
const pending: Promise<unknown>[] = [];
|
|
24
|
+
const res = await worker.fetch(new Request(BASE + path, { method, headers: { "content-type": "application/json", authorization: auth }, body: json ? JSON.stringify(json) : undefined }) as any,
|
|
25
|
+
env, { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} } as any);
|
|
26
|
+
// settle the request's background work (the 20 s debounce timer is never needed here)
|
|
27
|
+
await Promise.race([Promise.allSettled(pending), new Promise((r) => setTimeout(r, 200))]);
|
|
28
|
+
return res.json().catch(() => null);
|
|
29
|
+
};
|
|
30
|
+
const tok = (await call("POST", "/owner/peers", { label: "barry" })).token;
|
|
31
|
+
const msg = (text: string) => call("POST", "/", { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { kind: "message", role: "user", messageId: crypto.randomUUID(), contextId: "ctx-r", parts: [{ kind: "text", text }] } } }, `Bearer ${tok}`);
|
|
32
|
+
await msg("one");
|
|
33
|
+
assert.equal(sent.length, 1);
|
|
34
|
+
const row = () => env.DB.db.prepare("SELECT * FROM wakes WHERE context_id = 'ctx-r'").get() as any;
|
|
35
|
+
assert.ok(row().pending_json, "kept pending after the 429");
|
|
36
|
+
offset += 60_000;
|
|
37
|
+
await call("GET", "/health");
|
|
38
|
+
assert.equal(sent.length, 1, "not before Retry-After");
|
|
39
|
+
offset += 61_000;
|
|
40
|
+
await call("GET", "/health");
|
|
41
|
+
assert.equal(sent.length, 2, "re-sent after Retry-After");
|
|
42
|
+
assert.equal(JSON.parse(row().pending_json).attempts, 2);
|
|
43
|
+
for (let i = 0; i < 3; i++) { offset += 121_000; await call("GET", "/health"); }
|
|
44
|
+
assert.equal(sent.length, 4, "3 retries, then dropped");
|
|
45
|
+
assert.equal(row().pending_json, null);
|
|
46
|
+
// a success clears it for good
|
|
47
|
+
answer = 200;
|
|
48
|
+
offset += 3_600_000;
|
|
49
|
+
await msg("two");
|
|
50
|
+
assert.equal(sent.length, 5);
|
|
51
|
+
assert.equal(row().pending_json, null);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a 429 is due at Retry-After when the debounce is longer", async (t) => {
|
|
55
|
+
const env: any = { DB: d1(new URL("../migrations/", import.meta.url)), PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", WAKE_PRESET: "generic",
|
|
56
|
+
WAKE_WEBHOOK_URL: "https://example.com/hook", WAKE_WEBHOOK_KEY: "k", WAKE_DEBOUNCE_SECONDS: "120" };
|
|
57
|
+
const sent: any[] = [];
|
|
58
|
+
const orig = globalThis.fetch;
|
|
59
|
+
globalThis.fetch = (async () => { sent.push(1); return new Response("{}", { status: 429, headers: { "retry-after": "30" } }); }) as any;
|
|
60
|
+
t.after(() => { globalThis.fetch = orig; });
|
|
61
|
+
const pending: Promise<unknown>[] = [];
|
|
62
|
+
const ectx = { waitUntil: (p: Promise<unknown>) => pending.push(p), passThroughOnException() {} };
|
|
63
|
+
const call = async (method: string, path: string, json?: any, auth = "Bearer owner-secret") => {
|
|
64
|
+
const res = await worker.fetch(new Request(BASE + path, { method, headers: { "content-type": "application/json", authorization: auth }, body: json ? JSON.stringify(json) : undefined }) as any, env, ectx as any);
|
|
65
|
+
await Promise.race([Promise.allSettled(pending), new Promise((r) => setTimeout(r, 200))]);
|
|
66
|
+
return res.json().catch(() => null);
|
|
67
|
+
};
|
|
68
|
+
const tok = (await call("POST", "/owner/peers", { label: "barry" })).token;
|
|
69
|
+
const before = Date.now();
|
|
70
|
+
await call("POST", "/", { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { kind: "message", role: "user", messageId: "m1", contextId: "ctx-long", parts: [{ kind: "text", text: "one" }] } } }, `Bearer ${tok}`);
|
|
71
|
+
assert.equal(sent.length, 1);
|
|
72
|
+
const at = await nextCronAt(resolveTenant(env), Date.now());
|
|
73
|
+
assert.ok(at != null && at >= before + 25_000 && at <= before + 35_000, `alarm ${at} is Retry-After, not the 120s debounce`);
|
|
74
|
+
});
|
package/worker/test/wake.test.ts
CHANGED
|
@@ -30,6 +30,24 @@ test("claude-code: anthropic-version + text body", async () => {
|
|
|
30
30
|
assert.deepEqual(Object.keys(b), ["text"]);
|
|
31
31
|
assert.match(b.text, /2 new messages from peer "peer-a"/);
|
|
32
32
|
assert.match(b.text, /untrusted/);
|
|
33
|
+
// a fresh routine session learns what it needs and what to do when setup is missing
|
|
34
|
+
assert.match(b.text, /A2A_BASE_URL and A2A_OWNER_TOKEN/);
|
|
35
|
+
assert.match(b.text, /a2a-exposed connector \(MCP tools/, "the connector comes first");
|
|
36
|
+
assert.ok(b.text.indexOf("connector") < b.text.indexOf("A2A_OWNER_TOKEN"), "the CLI is the fallback");
|
|
37
|
+
assert.match(b.text, /never ask for the token in chat/);
|
|
38
|
+
assert.match(b.text, /npx -y a2a-exposed@latest inbox/, "the default hint runs the CLI through npx");
|
|
39
|
+
assert.match(b.text, /run as `npx -y a2a-exposed@latest`, no global install/);
|
|
40
|
+
assert.ok(!/npm i(nstall)? -g/.test(b.text), "never asks for a global install");
|
|
41
|
+
assert.ok(!b.text.includes("Bearer rt") && !b.text.includes(" rt "), "no secret in the text");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("claude-code: a human-approved pairing wake stays CLI-free (no cold-start note)", async () => {
|
|
45
|
+
const pe = { ...ev, kind: "pairing_request" as const, taskIds: [], pairing: { userCode: "WDJB-4827", verificationUriComplete: "https://agent.example.com/device?user_code=WDJB-4827",
|
|
46
|
+
approval: "human" as const, clientName: "Peer", clientId: "", agentCardUrl: "", expiresIn: 600 } };
|
|
47
|
+
const r = (await renderWake({ preset: "claude-code", url: "https://api.anthropic.com/v1/claude_code/routines/trig_x/fire", key: "rt" }, pe))!;
|
|
48
|
+
const b = JSON.parse(r.body);
|
|
49
|
+
assert.match(b.text, /WDJB-4827/);
|
|
50
|
+
assert.ok(!b.text.includes("A2A_OWNER_TOKEN"), "human pairing needs no CLI or owner token");
|
|
33
51
|
});
|
|
34
52
|
|
|
35
53
|
test("openclaw-wake: no peer text, mode now, agentId", async () => {
|