a2a-exposed 0.7.11 → 0.7.13

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.
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
9
9
  import { Miniflare, convertV4MiniflareOptions } from "miniflare";
10
10
  import worker from "../src/index.ts";
11
11
  import { sha256 } from "../src/a2a.ts";
12
+ import { sealWakeSecrets } from "../src/wake-secrets.ts";
12
13
  import { d1 } from "./d1.ts";
13
14
 
14
15
  const require = createRequire(new URL("../package.json", import.meta.url));
@@ -213,6 +214,10 @@ test("per-tenant SQLite: directory, config push, and isolation", async (t) => {
213
214
  const aliceInbox = await (await call("https://alice.example.com/owner/inbox?all=1", "GET", `Bearer ${tokenA}`)).json() as { text: string }[];
214
215
  assert.equal(aliceInbox.length, 1);
215
216
  assert.match(aliceInbox[0]!.text, /hello alice/);
217
+ const aliceSummary = await (await call("https://alice.example.com/owner/summary", "GET", `Bearer ${tokenA}`)).json() as { tasks: { submitted: number }; pairingPending: number };
218
+ assert.equal(aliceSummary.tasks.submitted, 1);
219
+ assert.equal(aliceSummary.pairingPending, 0);
220
+ assert.equal(JSON.stringify(aliceSummary).includes("hello alice"), false);
216
221
 
217
222
  // workerd's local runtime throws "Jurisdiction restrictions are not implemented" on namespace.jurisdiction().
218
223
  // namespaceForRegion() is covered in tenancy.test.ts; production Durable Objects do honour eu / fedramp.
@@ -481,3 +486,226 @@ test("an apex issuer is fetched with that Host, and a control worker that does n
481
486
  assert.equal(stalledPost.status, 502);
482
487
  assert.match(await stalledPost.text(), /The identity provider could not be reached/);
483
488
  });
489
+
490
+ test("TenantStore.owner runs an allowlisted owner call and does not return a peer token", async (t) => {
491
+ const mf = await start({
492
+ bindings: {
493
+ TENANCY: "host",
494
+ TENANT_DOMAIN: "example.com",
495
+ TENANT_SECRETS_KEY: "platform-secret",
496
+ OWNER_TOKEN: "platform-owner",
497
+ },
498
+ kvNamespaces: { TENANT_DIRECTORY: "tenants-owner-rpc" },
499
+ durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
500
+ });
501
+ t.after(() => mf.dispose());
502
+ await mf.ready;
503
+ const kv = await mf.getKVNamespace("TENANT_DIRECTORY");
504
+ const ns = await mf.getDurableObjectNamespace("TENANT_DO");
505
+ const hash = await sha256("owner-carol");
506
+ type OwnerStub = {
507
+ owner(input: { accountId: string; method: string; path: string; query?: Record<string, string>; body?: unknown; actor?: string }): Promise<{ status: number; body: unknown }>;
508
+ storageStatus(): Promise<{ configured: boolean; tables: string[] }>;
509
+ pushConfig(body: Record<string, unknown>): Promise<{ applied: boolean }>;
510
+ };
511
+ const lines: string[] = [];
512
+ const orig = console.log;
513
+ console.log = (...args: unknown[]) => {
514
+ lines.push(args.map((a) => String(a)).join(" "));
515
+ orig(...args);
516
+ };
517
+ t.after(() => { console.log = orig; });
518
+ // Workerd delivers console.log on a later turn. A busy runner (the storage-backend jobs) can
519
+ // return the RPC before that line is in this process.
520
+ const saw = async (part: string) => {
521
+ const start = Date.now();
522
+ while (!lines.some((l) => l.includes(part))) {
523
+ if (Date.now() - start > 2000) return false;
524
+ await new Promise((r) => setTimeout(r, 20));
525
+ }
526
+ return true;
527
+ };
528
+ const sawAfter = async (from: number, part: string) => {
529
+ const start = Date.now();
530
+ while (!lines.slice(from).some((l) => l.includes(part))) {
531
+ if (Date.now() - start > 2000) return false;
532
+ await new Promise((r) => setTimeout(r, 20));
533
+ }
534
+ return true;
535
+ };
536
+ const empty = ns.get(ns.idFromName("id-empty")) as unknown as OwnerStub;
537
+ const missing = await empty.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
538
+ assert.equal(missing.status, 404);
539
+ const emptyStatus = await empty.storageStatus();
540
+ assert.equal(emptyStatus.configured, false);
541
+ assert.deepEqual(emptyStatus.tables, []);
542
+ assert.equal(await saw('"reason":"absent"'), true);
543
+
544
+ const stub = ns.get(ns.idFromName("id-carol")) as unknown as OwnerStub;
545
+ await stub.pushConfig({
546
+ version: 1, tenantId: "id-carol", name: "carol", status: "active", ownerTokenHash: hash,
547
+ approval: { issuer: "https://idp.example", clientId: "client-1", clientSecret: "tenant-secret-value", allowedSubjects: ["acct_carol"] },
548
+ config: { AGENT_NAME: "Carol" },
549
+ });
550
+ await kv.put("tenant:carol", JSON.stringify({ id: "id-carol", status: "active", region: "", version: 1 }));
551
+ const http = (method: string, path: string, auth: string, json?: unknown) => mf.dispatchFetch(`https://carol.example.com${path}`, {
552
+ method, headers: { authorization: auth, "content-type": "application/json" }, body: json === undefined ? undefined : JSON.stringify(json),
553
+ });
554
+ assert.equal((await http("POST", "/owner/peers", "Bearer platform-owner", { label: "nope" })).status, 401, "self-host owner token still does not unlock a tenant");
555
+ const issued = await (await http("POST", "/owner/peers", "Bearer owner-carol", { label: "ada" })).json() as { token?: string };
556
+ assert.match(issued.token ?? "", /^a2aow_/, "the owner HTTP API still returns the new token");
557
+ const sentCarol = await http("POST", "/", `Bearer ${issued.token}`, {
558
+ jsonrpc: "2.0", id: 1, method: "message/send",
559
+ params: { message: { kind: "message", role: "user", messageId: "m-carol", parts: [{ kind: "text", text: "hello from carol peer" }] } },
560
+ });
561
+ assert.equal(sentCarol.status, 200, await sentCarol.clone().text());
562
+ const summary = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/summary" });
563
+ assert.equal(summary.status, 200);
564
+ const summaryBody = summary.body as { tasks?: { submitted?: number }; pairingPending?: number };
565
+ assert.equal(summaryBody.tasks?.submitted, 1);
566
+ assert.equal(summaryBody.pairingPending, 0);
567
+ assert.equal(JSON.stringify(summary.body).includes("hello from carol peer"), false);
568
+ assert.equal(await saw('"route":"GET /owner/summary"'), true);
569
+
570
+ const listed = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
571
+ assert.equal(listed.status, 200);
572
+ const peers = listed.body as { label: string; token?: string }[];
573
+ assert.deepEqual(peers.map((p) => p.label), ["ada"]);
574
+ assert.equal(JSON.stringify(listed.body).includes("a2aow_"), false);
575
+ assert.equal(await saw('"route":"GET /owner/peers"'), true);
576
+
577
+ const minted = await stub.owner({ accountId: "acct_carol", method: "POST", path: "/owner/peers", body: { label: "bob" } });
578
+ assert.equal(minted.status, 404);
579
+ assert.equal(JSON.stringify(minted.body).includes("a2aow_"), false);
580
+ const withBody = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers", body: { label: "bob" } });
581
+ assert.equal(withBody.status, 400);
582
+ const again = await stub.owner({ accountId: "acct_carol", method: "POST", path: "/owner/peers" });
583
+ assert.equal(again.status, 404);
584
+ const still = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
585
+ assert.deepEqual((still.body as { label: string }[]).map((p) => p.label), ["ada"], "a refused call does not mint a token");
586
+
587
+ const setup = await stub.owner({ accountId: "acct_carol", method: "POST", path: "/owner/pairing/password-link" });
588
+ assert.equal(setup.status, 404);
589
+ const dumped = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/export" });
590
+ assert.equal(dumped.status, 404);
591
+ const oddQuery = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/inbox", query: { format: "sql" } });
592
+ assert.equal(oddQuery.status, 400);
593
+ const pairing = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/pairing" });
594
+ assert.equal(pairing.status, 200);
595
+ assert.equal(JSON.stringify(pairing.body).includes("tenant-secret-value"), false);
596
+ const preview = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/wake/preview" });
597
+ assert.equal(preview.status, 200);
598
+ assert.equal(JSON.stringify(preview.body).includes("platform-secret"), false);
599
+ assert.equal(JSON.stringify(preview.body).includes("tenant-secret-value"), false);
600
+
601
+ const wakeKey = "z9Qx-secret-value-0199";
602
+ const sealed = await sealWakeSecrets("platform-secret", "id-carol", { WAKE_WEBHOOK_KEY: wakeKey });
603
+ assert.ok(sealed);
604
+ assert.equal(sealed!.includes(wakeKey), false);
605
+ await stub.pushConfig({
606
+ version: 2, tenantId: "id-carol", name: "carol", status: "active", ownerTokenHash: hash,
607
+ secretsEnc: sealed,
608
+ config: {
609
+ AGENT_NAME: "Carol",
610
+ WAKE_WEBHOOK_URL: "https://hooks.example/wake",
611
+ WAKE_PRESET: "generic",
612
+ WAKE_KEY_HEADER: "X-Credential",
613
+ WAKE_WEBHOOK_KEY: "plaintext-key-should-not-appear",
614
+ },
615
+ });
616
+ const woken = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/wake/preview" });
617
+ assert.equal(woken.status, 200);
618
+ const wokenText = JSON.stringify(woken.body);
619
+ assert.equal(wokenText.includes(wakeKey), false);
620
+ assert.equal(wokenText.includes("plaintext-key-should-not-appear"), false);
621
+ assert.equal(wokenText.includes("platform-secret"), false);
622
+ const wokenBody = woken.body as { hasKey?: boolean; request?: { headers?: Record<string, string> } };
623
+ assert.equal(wokenBody.hasKey, true);
624
+ assert.equal(wokenBody.request?.headers?.["x-credential"], "Bearer ••••");
625
+ assert.equal(wokenText.includes("z9Qx"), false);
626
+ const httpPreview = await (await http("GET", "/owner/wake/preview", "Bearer owner-carol")).json() as { hasKey?: boolean };
627
+ assert.equal(httpPreview.hasKey, true);
628
+ assert.equal(JSON.stringify(httpPreview).includes(wakeKey), false);
629
+
630
+ const started = await mf.dispatchFetch("https://carol.example.com/oauth/device_authorization", {
631
+ method: "POST",
632
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.20" },
633
+ body: "client_name=Barry+Bot&client_id=barry-alias&agent_card_url=https://barry.example/.well-known/agent-card.json",
634
+ });
635
+ const device = await started.json() as { user_code?: string };
636
+ assert.equal(started.status, 200, JSON.stringify(device));
637
+ const code = device.user_code ?? "";
638
+ assert.match(code, /^[BCDFGHJKLMNPQRSTVWXZ]{4}-[2-9]{4}$/);
639
+ const shapedName = "a2aow_" + "a".repeat(20);
640
+ const shapedCard = "https://card.example/card.json?t=" + "c".repeat(16);
641
+ const shapedStart = await mf.dispatchFetch("https://carol.example.com/oauth/device_authorization", {
642
+ method: "POST",
643
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.23" },
644
+ body: `client_name=${encodeURIComponent(shapedName)}&client_id=shaped-alias&agent_card_url=${encodeURIComponent(shapedCard)}`,
645
+ });
646
+ assert.equal(shapedStart.status, 200);
647
+ const shapedList = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/pairing" });
648
+ assert.equal(shapedList.status, 200);
649
+ const shapedText = JSON.stringify(shapedList.body);
650
+ assert.equal(shapedText.includes(shapedName), false);
651
+ assert.equal(shapedText.includes("t=" + "c".repeat(16)), false);
652
+ assert.match(shapedText, /Barry Bot/);
653
+ const pendingPairing = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/pairing" });
654
+ const pending = (pendingPairing.body as { pending?: { userCode?: string; clientName?: string; clientId?: string; agentCardUrl?: string }[] }).pending ?? [];
655
+ assert.equal(pending.some((row) => row.userCode === code && row.clientName === "Barry Bot" && row.clientId === "barry-alias" && row.agentCardUrl === "https://barry.example/.well-known/agent-card.json"), true);
656
+ const refused = await stub.owner({ accountId: "acct_carol", method: "POST", path: `/owner/pairing/${code}/approve` });
657
+ assert.equal(refused.status, 403);
658
+ const httpSpoof = await mf.dispatchFetch(`https://carol.example.com/owner/pairing/${code}/approve`, {
659
+ method: "POST",
660
+ headers: { authorization: "Bearer owner-carol", "content-type": "application/json", "x-a2a-dashboard-actor": "human" },
661
+ });
662
+ assert.equal(httpSpoof.status, 403);
663
+ const approved = await stub.owner({ accountId: "acct_carol", method: "POST", path: `/owner/pairing/${code}/approve`, actor: "human" });
664
+ assert.equal(approved.status, 200);
665
+ assert.equal((approved.body as { decision?: string }).decision, "approved");
666
+ assert.equal(await saw('"event":"pairing_approved"'), true);
667
+ assert.equal(lines.some((line) => line.includes('"event":"pairing_approved"') && line.includes('"by":"human"')), true);
668
+
669
+ const deniedStart = await mf.dispatchFetch("https://carol.example.com/oauth/device_authorization", {
670
+ method: "POST",
671
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.21" },
672
+ body: "client_name=Deny+Bot&client_id=deny-alias",
673
+ });
674
+ const deniedDevice = await deniedStart.json() as { user_code?: string };
675
+ assert.equal(deniedStart.status, 200);
676
+ const ownerDenyAt = lines.length;
677
+ const ownerDenied = await stub.owner({ accountId: "acct_carol", method: "POST", path: `/owner/pairing/${deniedDevice.user_code}/deny` });
678
+ assert.equal(ownerDenied.status, 200);
679
+ assert.equal(await saw('"event":"pairing_denied"'), true);
680
+ const ownerDenyLine = lines.slice(ownerDenyAt).find((line) => line.includes('"event":"pairing_denied"')) ?? "";
681
+ assert.match(ownerDenyLine, /"by":"owner"/);
682
+ assert.equal(ownerDenyLine.includes('"by":"human"'), false);
683
+
684
+ const humanDenyStart = await mf.dispatchFetch("https://carol.example.com/oauth/device_authorization", {
685
+ method: "POST",
686
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.22" },
687
+ body: "client_name=Human+Deny&client_id=human-deny",
688
+ });
689
+ const humanDenyDevice = await humanDenyStart.json() as { user_code?: string };
690
+ assert.equal(humanDenyStart.status, 200);
691
+ const humanDenyAt = lines.length;
692
+ const humanDenied = await stub.owner({ accountId: "acct_carol", method: "POST", path: `/owner/pairing/${humanDenyDevice.user_code}/deny`, actor: "human" });
693
+ assert.equal(humanDenied.status, 200);
694
+ assert.equal(await sawAfter(humanDenyAt, '"by":"human"'), true);
695
+ assert.equal(lines.slice(humanDenyAt).some((line) => line.includes('"event":"pairing_denied"') && line.includes('"by":"human"')), true);
696
+
697
+ const flagged = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers", actor: "human" });
698
+ assert.equal(flagged.status, 400);
699
+ const badActor = await stub.owner({ accountId: "acct_carol", method: "POST", path: "/owner/pairing/WDJB-4827/approve", actor: "agent" });
700
+ assert.equal(badActor.status, 400);
701
+ assert.equal(await saw('"reason":"actor"'), true);
702
+
703
+ await stub.pushConfig({ version: 3, tenantId: "id-carol", name: "carol", status: "suspended", ownerTokenHash: hash, config: { AGENT_NAME: "Carol" } });
704
+ const suspended = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
705
+ assert.equal(suspended.status, 403);
706
+ assert.equal(await saw('"reason":"status"'), true);
707
+ const badAccount = await stub.owner({ accountId: "../acct", method: "GET", path: "/owner/peers" });
708
+ assert.equal(badAccount.status, 400);
709
+ assert.equal(await saw('"reason":"account"'), true);
710
+ assert.equal(lines.some((l) => l.includes("../acct")), false);
711
+ });
@@ -0,0 +1,107 @@
1
+ // Owner RPC allowlist and response filter. No Durable Object: the hosted test calls TenantStore.owner.
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { accountIdOf, matchOwnerRpc, ownerResponseLeaks, scrubOwnerClaims } from "../src/owner-rpc.ts";
5
+
6
+ test("owner RPC allowlist is the management reads and the calls that do not return a secret", () => {
7
+ const ok = (method: string, path: string, query?: Record<string, string>) => {
8
+ const m = matchOwnerRpc(method, path, query);
9
+ assert.equal(m.ok, true, `${method} ${path}`);
10
+ return m.ok ? m.route : null;
11
+ };
12
+ assert.equal(ok("GET", "/owner/inbox")?.name, "GET /owner/inbox");
13
+ assert.equal(ok("GET", "/owner/summary")?.name, "GET /owner/summary");
14
+ assert.equal(ok("GET", "/owner/inbox", { context: "ctx-1", all: "1" })?.search, "?context=ctx-1&all=1");
15
+ assert.equal(ok("GET", "/owner/tasks/task-1")?.path, "/owner/tasks/task-1");
16
+ assert.equal(ok("GET", "/owner/history/ctx-1", { n: "50" })?.search, "?n=50");
17
+ assert.equal(ok("GET", "/owner/outbound/task-1")?.name, "GET /owner/outbound/:id");
18
+ assert.equal(ok("GET", "/owner/peers")?.name, "GET /owner/peers");
19
+ assert.equal(ok("DELETE", "/owner/peers/ada")?.path, "/owner/peers/ada");
20
+ assert.equal(ok("GET", "/owner/pairing")?.name, "GET /owner/pairing");
21
+ assert.equal(ok("POST", "/owner/pairing/WDJB-4827/approve")?.name, "POST /owner/pairing/:code/approve");
22
+ assert.equal(ok("POST", "/owner/pairing/wdjb4827/deny")?.name, "POST /owner/pairing/:code/deny");
23
+ assert.equal(ok("GET", "/owner/mcp")?.name, "GET /owner/mcp");
24
+ assert.equal(ok("DELETE", "/owner/mcp/mcp-claude")?.path, "/owner/mcp/mcp-claude");
25
+ assert.equal(ok("GET", "/owner/outbound-peers")?.name, "GET /owner/outbound-peers");
26
+ assert.equal(ok("DELETE", "/owner/outbound-peers/ada.bot")?.path, "/owner/outbound-peers/ada.bot");
27
+ assert.equal(ok("GET", "/owner/facade")?.name, "GET /owner/facade");
28
+ assert.equal(ok("POST", "/owner/facade/verify")?.name, "POST /owner/facade/verify");
29
+ assert.equal(ok("GET", "/owner/wake/preview")?.name, "GET /owner/wake/preview");
30
+ assert.equal(ok("POST", "/owner/wake/test")?.name, "POST /owner/wake/test");
31
+ assert.equal(ok("GET", "/owner/contexts")?.name, "GET /owner/contexts");
32
+
33
+ const refused = (method: string, path: string, query?: Record<string, string>) => matchOwnerRpc(method, path, query);
34
+ assert.deepEqual(refused("POST", "/owner/peers"), { ok: false, reason: "path" });
35
+ assert.deepEqual(refused("POST", "/owner/pairing/password-link"), { ok: false, reason: "path" });
36
+ assert.deepEqual(refused("PUT", "/owner/pairing/password"), { ok: false, reason: "path" });
37
+ assert.deepEqual(refused("PUT", "/owner/outbound-peers/ada"), { ok: false, reason: "path" });
38
+ assert.deepEqual(refused("GET", "/owner/export"), { ok: false, reason: "path" });
39
+ assert.deepEqual(refused("POST", "/owner/purge"), { ok: false, reason: "path" });
40
+ assert.deepEqual(refused("POST", "/owner/tasks/task-1/reply"), { ok: false, reason: "path" });
41
+ assert.deepEqual(refused("POST", "/owner/history"), { ok: false, reason: "path" });
42
+ assert.deepEqual(refused("GET", "/owner/peers/../export"), { ok: false, reason: "path" });
43
+ assert.deepEqual(refused("GET", "/owner/%2e%2e/peers"), { ok: false, reason: "path" });
44
+ assert.deepEqual(refused("GET", "/owner/inbox?all=1"), { ok: false, reason: "path" });
45
+ assert.deepEqual(refused("POST", "/owner/pairing/AEIO-4827/approve"), { ok: false, reason: "path" });
46
+ assert.deepEqual(refused("GET", "/owner/inbox", { format: "sql" }), { ok: false, reason: "query" });
47
+ assert.deepEqual(refused("GET", "/owner/summary", { all: "1" }), { ok: false, reason: "query" });
48
+ assert.deepEqual(refused("GET", "/owner/history/ctx-1", { n: "1001" }), { ok: false, reason: "query" });
49
+ assert.deepEqual(refused("GET", "/owner/history/.."), { ok: false, reason: "path" });
50
+ assert.equal(accountIdOf("acct_carol"), "acct_carol");
51
+ assert.equal(accountIdOf("../acct"), null);
52
+ assert.equal(accountIdOf(""), null);
53
+ });
54
+
55
+ test("owner RPC withholds peer tokens and decrypted secrets", () => {
56
+ const token = "a2aow_" + "a".repeat(43);
57
+ assert.equal(ownerResponseLeaks({ label: "ada", revoked_at: null }), false);
58
+ assert.equal(ownerResponseLeaks([{ text: "hello" }]), false);
59
+ assert.equal(ownerResponseLeaks({ token }), true);
60
+ assert.equal(ownerResponseLeaks({ nested: { access_token: "a2amcp_" + "b".repeat(30) } }), true);
61
+ assert.equal(ownerResponseLeaks({ url: "https://agent.example/device/setup?t=" + "c".repeat(20) }), true);
62
+ assert.equal(ownerResponseLeaks({ clientSecret: "tenant-secret-value" }), true);
63
+ assert.equal(ownerResponseLeaks({ secretsEnc: "v1.ciphertext-value" }), true);
64
+ assert.equal(ownerResponseLeaks("https://user:pass@hooks.example/wake"), true);
65
+ assert.equal(ownerResponseLeaks({ method: "POST", headers: { authorization: "Bearer abcd…" } }), true);
66
+ assert.equal(ownerResponseLeaks({ method: "POST", headers: { authorization: "Bearer ••••" } }), false);
67
+ assert.equal(ownerResponseLeaks({ method: "POST", headers: { "x-credential": "••••" } }), false);
68
+ assert.equal(ownerResponseLeaks({
69
+ request: { method: "POST", url: "https://hooks.example/wake", headers: { "x-credential": "supersecretvalue", "content-type": "application/json" }, body: "{}" },
70
+ }), true);
71
+ assert.equal(ownerResponseLeaks({
72
+ request: { method: "POST", url: "https://hooks.example/wake", headers: { "x-credential": "Bearer ••••", "content-type": "application/json", "x-request-id": "req-1" }, body: "{}" },
73
+ }), false);
74
+ assert.equal(ownerResponseLeaks({ fingerprints: { url: "0123456789ab", key: null } }), false);
75
+ assert.equal(ownerResponseLeaks({ token_hash: "ab".repeat(32) }), false);
76
+ });
77
+
78
+ test("a token-shaped claim is blanked and the rest of the list is returned", () => {
79
+ const token = "a2aow_" + "a".repeat(20);
80
+ const setup = "https://card.example/card.json?t=" + "c".repeat(16);
81
+ const userinfo = "https://user:secret@card.example/card.json";
82
+ const raw = {
83
+ pending: [
84
+ { userCode: "WDJB-4827", clientName: token, clientId: "barry-alias", agentCardUrl: setup },
85
+ { userCode: "BCDF-2345", clientName: "Barry Bot", clientId: token, agentCardUrl: "https://barry.example/card.json" },
86
+ { client_name: token, agent_card_url: userinfo },
87
+ ],
88
+ token,
89
+ };
90
+ assert.equal(ownerResponseLeaks(raw), true);
91
+ const scrubbed = scrubOwnerClaims(raw) as { pending: { clientName: string; clientId: string; agentCardUrl: string; client_name?: string; agent_card_url?: string }[]; token: string };
92
+ assert.equal(scrubbed.pending[0]!.clientName, "");
93
+ assert.equal(scrubbed.pending[0]!.clientId, "barry-alias");
94
+ assert.equal(scrubbed.pending[0]!.agentCardUrl, "");
95
+ assert.equal(scrubbed.pending[1]!.clientName, "Barry Bot");
96
+ assert.equal(scrubbed.pending[1]!.clientId, "");
97
+ assert.equal(scrubbed.pending[1]!.agentCardUrl, "https://barry.example/card.json");
98
+ assert.equal(scrubbed.pending[2]!.client_name, "");
99
+ assert.equal(scrubbed.pending[2]!.agent_card_url, "");
100
+ assert.equal(scrubbed.token, token);
101
+ assert.equal(ownerResponseLeaks(scrubbed), true, "a real token under another key is still withheld");
102
+ const listed = scrubOwnerClaims({ pending: raw.pending });
103
+ assert.equal(ownerResponseLeaks(listed), false);
104
+ assert.equal(JSON.stringify(listed).includes(token), false);
105
+ assert.equal(JSON.stringify(listed).includes("t=" + "c".repeat(16)), false);
106
+ assert.equal(JSON.stringify(listed).includes("user:secret"), false);
107
+ });
@@ -3,6 +3,7 @@
3
3
  import { test } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import worker from "../src/index.ts";
6
+ import { DASHBOARD_ACTOR_HEADER } from "../src/owner-rpc.ts";
6
7
  import * as P from "../src/pairing.ts";
7
8
  import { wakeSummary, renderWake, type WakeEvent } from "../src/wake.ts";
8
9
  import { d1 } from "./d1.ts";
@@ -221,10 +222,13 @@ test("human approval: pending, slow_down, page approve with the password, single
221
222
  advance(16);
222
223
  assert.equal((await s.poll(d.device_code)).data.error, "authorization_pending");
223
224
 
224
- // human mode: the owner API (the agent) cannot approve
225
+ // human mode: the owner API (the agent) cannot approve, and the dashboard header does not change that
225
226
  const oa = await s.owner("POST", `/owner/pairing/${d.user_code}/approve`);
226
227
  assert.equal(oa.status, 403);
227
228
  assert.match(oa.data.error, /approval mode is human: the owner approves on https:\/\/agent\.example\.com\/device\?user_code=/);
229
+ const spoofed = await s.call("POST", `/owner/pairing/${d.user_code}/approve`, { headers: { authorization: "Bearer owner-secret", [DASHBOARD_ACTOR_HEADER]: "human" } });
230
+ assert.equal(spoofed.status, 403);
231
+ assert.match(spoofed.data.error, /approval mode is human/);
228
232
  const list = await s.owner("GET", "/owner/pairing");
229
233
  assert.deepEqual([list.data.mode, list.data.passwordSet, list.data.pending.length, list.data.pending[0].userCode], ["human", false, 1, d.user_code]);
230
234
 
@@ -289,10 +293,19 @@ test("human approval: pending, slow_down, page approve with the password, single
289
293
  });
290
294
 
291
295
  test("a pairing label comes from the client name, a second one is suffixed, and inbox from uses it", async (t) => {
296
+ const lines: string[] = [];
297
+ const orig = console.log;
298
+ console.log = (...args: unknown[]) => { lines.push(args.map((part) => String(part)).join(" ")); orig(...args); };
299
+ t.after(() => { console.log = orig; });
292
300
  const s = setup({ PAIRING_APPROVAL: "agent" }); t.after(s.restore);
293
301
  const first = (await s.start({ client_name: "tester-alpha", client_id: "other-alias" }, "203.0.113.40")).data;
294
- const approved = await s.owner("POST", `/owner/pairing/${first.user_code}/approve`);
302
+ const approved = await s.call("POST", `/owner/pairing/${first.user_code}/approve`, { headers: { authorization: "Bearer owner-secret", [DASHBOARD_ACTOR_HEADER]: "human" } });
295
303
  assert.equal(approved.data.label, "tester-alpha");
304
+ const decided = s.DB.db.prepare("SELECT decided_by FROM device_requests WHERE user_code = ?").get(first.user_code.replace("-", "")) as { decided_by: string };
305
+ assert.equal(decided.decided_by, "agent");
306
+ const logged = lines.find((line) => line.includes("pairing_approved"));
307
+ assert.match(logged ?? "", /"by":"agent"/);
308
+ assert.equal((logged ?? "").includes('"by":"human"'), false);
296
309
  const token = (await s.poll(first.device_code)).data.access_token;
297
310
  const sent = await s.call("POST", "/", {
298
311
  json: { jsonrpc: "2.0", id: 1, method: "SendMessage", params: { message: { role: "ROLE_USER", parts: [{ text: "hello" }] } } },
@@ -681,3 +694,31 @@ test("GET / with pairing off: no /device link, says to ask the operator", async
681
694
  assert.match(h.text, /ask this agent's operator for a bearer token/);
682
695
  assert.equal((await s.call("GET", "/")).data.pairing, undefined);
683
696
  });
697
+
698
+ test("owner summary counts tasks and pending pairing requests without their text", async (t) => {
699
+ const s = setup(); t.after(s.restore);
700
+ const started = await s.start();
701
+ assert.equal(started.status, 200);
702
+ const issued = await s.owner("POST", "/owner/peers", { label: "ada" });
703
+ assert.match(issued.data.token, /^a2aow_/);
704
+ const sent = await s.call("POST", "/", {
705
+ json: { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { kind: "message", role: "user", messageId: "m1", parts: [{ kind: "text", text: "secret plan from ada" }] } } },
706
+ headers: { authorization: `Bearer ${issued.data.token}` },
707
+ });
708
+ assert.equal(sent.status, 200, JSON.stringify(sent.data));
709
+ const taskId = sent.data.result.id;
710
+ const pending = JSON.stringify({ taskIds: [taskId] });
711
+ const now = Date.now();
712
+ s.DB.db.prepare("INSERT INTO wakes (context_id, last_sent_ms, pending_json, pending_since_ms) VALUES (?, ?, ?, ?)").run("ctx-keep", now, pending, now);
713
+ const summary = await s.owner("GET", "/owner/summary");
714
+ assert.equal(summary.status, 200);
715
+ assert.equal(summary.data.tasks.submitted, 1);
716
+ assert.equal(summary.data.tasks.working, 0);
717
+ assert.equal(summary.data.pairingPending, 1);
718
+ const text = JSON.stringify(summary.data);
719
+ assert.equal(text.includes("secret plan"), false);
720
+ assert.equal(text.includes(started.data.user_code), false);
721
+ assert.equal(text.includes(started.data.device_code), false);
722
+ assert.equal(text.includes(taskId), false);
723
+ assert.equal(s.DB.db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").get("ctx-keep")?.pending_json, pending);
724
+ });
@@ -0,0 +1,49 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { MAX_WAKE_SEALED, MAX_WAKE_VALUE, openWakeSecrets, sealWakeSecrets, WAKE_SECRET_KEYS } from "../src/wake-secrets.ts";
4
+
5
+ const key = "platform-secret";
6
+ const tenant = "ten_a";
7
+
8
+ test("wake secrets round-trip for one tenant and stay unreadable for another", async () => {
9
+ const secrets = { WAKE_WEBHOOK_KEY: "z9Qx-secret-value-0199", WAKE_HMAC_SECRET: "hmac-value" };
10
+ const sealed = await sealWakeSecrets(key, tenant, secrets);
11
+ assert.ok(sealed);
12
+ assert.equal(sealed!.includes("z9Qx-secret-value-0199"), false);
13
+ assert.equal(sealed!.startsWith("v1."), true);
14
+ assert.deepEqual(await openWakeSecrets(key, tenant, sealed), secrets);
15
+ assert.equal(await openWakeSecrets(key, "ten_b", sealed), null);
16
+ assert.equal(await openWakeSecrets("other-key", tenant, sealed), null);
17
+ assert.equal(await openWakeSecrets("", tenant, sealed), null);
18
+ assert.deepEqual(await openWakeSecrets(key, tenant, null), {});
19
+ assert.equal(await openWakeSecrets(key, tenant, sealed + ".extra"), null);
20
+ assert.equal(await openWakeSecrets(key, tenant, "v1.not-base64.nope"), null);
21
+ });
22
+
23
+ test("a wake secret blob keeps only the known fields", async () => {
24
+ const sealed = await sealWakeSecrets(key, tenant, {
25
+ WAKE_WEBHOOK_KEY: "hook-key",
26
+ WAKE_ACCESS_CLIENT_SECRET: "access-secret",
27
+ });
28
+ const opened = await openWakeSecrets(key, tenant, sealed);
29
+ assert.deepEqual(opened, { WAKE_WEBHOOK_KEY: "hook-key", WAKE_ACCESS_CLIENT_SECRET: "access-secret" });
30
+ assert.equal(await sealWakeSecrets(key, tenant, { WAKE_WEBHOOK_KEY: "x".repeat(4097) }), null);
31
+ });
32
+
33
+ test("four maximum-length wake secrets round-trip", async () => {
34
+ const value = "x".repeat(MAX_WAKE_VALUE);
35
+ const secrets = Object.fromEntries(WAKE_SECRET_KEYS.map((name) => [name, value]));
36
+ const sealed = await sealWakeSecrets(key, tenant, secrets);
37
+ assert.ok(sealed);
38
+ assert.ok(sealed!.length > 16384);
39
+ assert.ok(sealed!.length <= MAX_WAKE_SEALED);
40
+ assert.deepEqual(await openWakeSecrets(key, tenant, sealed), secrets);
41
+ assert.equal(await openWakeSecrets(key, tenant, "v1." + "a".repeat(MAX_WAKE_SEALED)), null);
42
+
43
+ const escaped = "\u0000".repeat(MAX_WAKE_VALUE);
44
+ const worst = Object.fromEntries(WAKE_SECRET_KEYS.map((name) => [name, escaped]));
45
+ const sealedWorst = await sealWakeSecrets(key, tenant, worst);
46
+ assert.ok(sealedWorst);
47
+ assert.ok(sealedWorst!.length <= MAX_WAKE_SEALED);
48
+ assert.deepEqual(await openWakeSecrets(key, tenant, sealedWorst), worst);
49
+ });