a2a-exposed 0.7.12 → 0.7.14

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.
@@ -214,10 +214,21 @@ test("per-tenant SQLite: directory, config push, and isolation", async (t) => {
214
214
  const aliceInbox = await (await call("https://alice.example.com/owner/inbox?all=1", "GET", `Bearer ${tokenA}`)).json() as { text: string }[];
215
215
  assert.equal(aliceInbox.length, 1);
216
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);
217
221
 
218
222
  // workerd's local runtime throws "Jurisdiction restrictions are not implemented" on namespace.jurisdiction().
219
223
  // namespaceForRegion() is covered in tenancy.test.ts; production Durable Objects do honour eu / fedramp.
220
- await alice.stub.pushConfig({ version: 4, tenantId: "id-alice", name: "alice", status: "suspended", ownerTokenHash: hashA, config: { AGENT_NAME: "Alice 3" } });
224
+ const tokenA2 = "owner-alice-2";
225
+ const hashA2 = await sha256(tokenA2);
226
+ const rotated = await alice.stub.pushConfig({ version: 4, tenantId: "id-alice", name: "alice", status: "active", ownerTokenHash: hashA2, config: { AGENT_NAME: "Alice 3" } });
227
+ assert.equal(rotated.applied, true);
228
+ assert.equal((await call("https://alice.example.com/owner/summary", "GET", `Bearer ${tokenA}`)).status, 401, "the previous owner token stops working");
229
+ assert.equal((await call("https://alice.example.com/owner/summary", "GET", `Bearer ${tokenA2}`)).status, 200);
230
+ assert.equal((await call("https://alice.example.com/owner/summary", "GET", "Bearer platform-owner")).status, 401, "self-host OWNER_TOKEN still does not unlock a tenant");
231
+ await alice.stub.pushConfig({ version: 5, tenantId: "id-alice", name: "alice", status: "suspended", ownerTokenHash: hashA2, config: { AGENT_NAME: "Alice 3" } });
221
232
  assert.equal((await mf.dispatchFetch("https://alice.example.com/health")).status, 403, "the object enforces a newer status than the directory cache");
222
233
  });
223
234
 
@@ -500,7 +511,7 @@ test("TenantStore.owner runs an allowlisted owner call and does not return a pee
500
511
  const ns = await mf.getDurableObjectNamespace("TENANT_DO");
501
512
  const hash = await sha256("owner-carol");
502
513
  type OwnerStub = {
503
- owner(input: { accountId: string; method: string; path: string; query?: Record<string, string>; body?: unknown }): Promise<{ status: number; body: unknown }>;
514
+ owner(input: { accountId: string; method: string; path: string; query?: Record<string, string>; body?: unknown; actor?: string }): Promise<{ status: number; body: unknown }>;
504
515
  storageStatus(): Promise<{ configured: boolean; tables: string[] }>;
505
516
  pushConfig(body: Record<string, unknown>): Promise<{ applied: boolean }>;
506
517
  };
@@ -521,6 +532,14 @@ test("TenantStore.owner runs an allowlisted owner call and does not return a pee
521
532
  }
522
533
  return true;
523
534
  };
535
+ const sawAfter = async (from: number, part: string) => {
536
+ const start = Date.now();
537
+ while (!lines.slice(from).some((l) => l.includes(part))) {
538
+ if (Date.now() - start > 2000) return false;
539
+ await new Promise((r) => setTimeout(r, 20));
540
+ }
541
+ return true;
542
+ };
524
543
  const empty = ns.get(ns.idFromName("id-empty")) as unknown as OwnerStub;
525
544
  const missing = await empty.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
526
545
  assert.equal(missing.status, 404);
@@ -542,6 +561,18 @@ test("TenantStore.owner runs an allowlisted owner call and does not return a pee
542
561
  assert.equal((await http("POST", "/owner/peers", "Bearer platform-owner", { label: "nope" })).status, 401, "self-host owner token still does not unlock a tenant");
543
562
  const issued = await (await http("POST", "/owner/peers", "Bearer owner-carol", { label: "ada" })).json() as { token?: string };
544
563
  assert.match(issued.token ?? "", /^a2aow_/, "the owner HTTP API still returns the new token");
564
+ const sentCarol = await http("POST", "/", `Bearer ${issued.token}`, {
565
+ jsonrpc: "2.0", id: 1, method: "message/send",
566
+ params: { message: { kind: "message", role: "user", messageId: "m-carol", parts: [{ kind: "text", text: "hello from carol peer" }] } },
567
+ });
568
+ assert.equal(sentCarol.status, 200, await sentCarol.clone().text());
569
+ const summary = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/summary" });
570
+ assert.equal(summary.status, 200);
571
+ const summaryBody = summary.body as { tasks?: { submitted?: number }; pairingPending?: number };
572
+ assert.equal(summaryBody.tasks?.submitted, 1);
573
+ assert.equal(summaryBody.pairingPending, 0);
574
+ assert.equal(JSON.stringify(summary.body).includes("hello from carol peer"), false);
575
+ assert.equal(await saw('"route":"GET /owner/summary"'), true);
545
576
 
546
577
  const listed = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
547
578
  assert.equal(listed.status, 200);
@@ -562,8 +593,42 @@ test("TenantStore.owner runs an allowlisted owner call and does not return a pee
562
593
 
563
594
  const setup = await stub.owner({ accountId: "acct_carol", method: "POST", path: "/owner/pairing/password-link" });
564
595
  assert.equal(setup.status, 404);
596
+ const pasted = "a2aow_" + "b".repeat(20);
597
+ const sentPaste = await http("POST", "/", `Bearer ${issued.token}`, {
598
+ jsonrpc: "2.0", id: 2, method: "message/send",
599
+ params: { message: { kind: "message", role: "user", messageId: "m-paste", parts: [{ kind: "text", text: `note ${pasted} end` }] } },
600
+ });
601
+ assert.equal(sentPaste.status, 200, await sentPaste.clone().text());
602
+ const httpExport = await (await http("GET", "/owner/export", "Bearer owner-carol")).json() as { tables?: { peers?: { token_hash?: string }[] } };
603
+ assert.match(httpExport.tables?.peers?.[0]?.token_hash ?? "", /^[0-9a-f]{64}$/);
604
+ assert.equal(JSON.stringify(httpExport).includes(pasted), true, "the owner HTTP export stays the full dump");
565
605
  const dumped = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/export" });
566
- assert.equal(dumped.status, 404);
606
+ assert.equal(dumped.status, 200);
607
+ const dumpedText = JSON.stringify(dumped.body);
608
+ assert.match(dumpedText, /"token_hash":"redacted:ada"/);
609
+ assert.match(dumpedText, /note \[redacted\] end/);
610
+ assert.equal(dumpedText.includes(pasted), false);
611
+ assert.equal(dumpedText.includes(issued.token ?? ""), false);
612
+ assert.equal(dumpedText.includes("tenant-secret-value"), false);
613
+ assert.equal(dumpedText.includes(httpExport.tables?.peers?.[0]?.token_hash ?? "missing-hash"), false);
614
+ const sqlDump = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/export", query: { format: "sql" } });
615
+ assert.equal(sqlDump.status, 200);
616
+ const sql = (sqlDump.body as { sql?: string }).sql ?? "";
617
+ assert.match(sql, /note \[redacted\] end/);
618
+ assert.match(sql, /redacted:ada/);
619
+ assert.match(sql, /Do not load it onto a live inbox/);
620
+ assert.equal(sql.includes(pasted), false);
621
+ assert.equal(sql.includes("oauth_codes"), false);
622
+ assert.equal(sql.includes("oidc_txns"), false);
623
+ assert.equal(sql.includes(issued.token ?? ""), false);
624
+ const badFormat = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/export", query: { format: "json" } });
625
+ assert.equal(badFormat.status, 400);
626
+ const logged = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/log" });
627
+ assert.equal(logged.status, 200);
628
+ const loggedText = JSON.stringify(logged.body);
629
+ assert.match(loggedText, /note \[redacted\] end/);
630
+ assert.equal(loggedText.includes(pasted), false);
631
+ assert.equal(loggedText.includes("data_json"), false);
567
632
  const oddQuery = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/inbox", query: { format: "sql" } });
568
633
  assert.equal(oddQuery.status, 400);
569
634
  const pairing = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/pairing" });
@@ -597,11 +662,85 @@ test("TenantStore.owner runs an allowlisted owner call and does not return a pee
597
662
  assert.equal(wokenText.includes("platform-secret"), false);
598
663
  const wokenBody = woken.body as { hasKey?: boolean; request?: { headers?: Record<string, string> } };
599
664
  assert.equal(wokenBody.hasKey, true);
600
- assert.equal(wokenBody.request?.headers?.["x-credential"], "Bearer z9Qx…");
665
+ assert.equal(wokenBody.request?.headers?.["x-credential"], "Bearer ••••");
666
+ assert.equal(wokenText.includes("z9Qx"), false);
601
667
  const httpPreview = await (await http("GET", "/owner/wake/preview", "Bearer owner-carol")).json() as { hasKey?: boolean };
602
668
  assert.equal(httpPreview.hasKey, true);
603
669
  assert.equal(JSON.stringify(httpPreview).includes(wakeKey), false);
604
670
 
671
+ const started = await mf.dispatchFetch("https://carol.example.com/oauth/device_authorization", {
672
+ method: "POST",
673
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.20" },
674
+ body: "client_name=Barry+Bot&client_id=barry-alias&agent_card_url=https://barry.example/.well-known/agent-card.json",
675
+ });
676
+ const device = await started.json() as { user_code?: string };
677
+ assert.equal(started.status, 200, JSON.stringify(device));
678
+ const code = device.user_code ?? "";
679
+ assert.match(code, /^[BCDFGHJKLMNPQRSTVWXZ]{4}-[2-9]{4}$/);
680
+ const shapedName = "a2aow_" + "a".repeat(20);
681
+ const shapedCard = "https://card.example/card.json?t=" + "c".repeat(16);
682
+ const shapedStart = await mf.dispatchFetch("https://carol.example.com/oauth/device_authorization", {
683
+ method: "POST",
684
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.23" },
685
+ body: `client_name=${encodeURIComponent(shapedName)}&client_id=shaped-alias&agent_card_url=${encodeURIComponent(shapedCard)}`,
686
+ });
687
+ assert.equal(shapedStart.status, 200);
688
+ const shapedList = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/pairing" });
689
+ assert.equal(shapedList.status, 200);
690
+ const shapedText = JSON.stringify(shapedList.body);
691
+ assert.equal(shapedText.includes(shapedName), false);
692
+ assert.equal(shapedText.includes("t=" + "c".repeat(16)), false);
693
+ assert.match(shapedText, /Barry Bot/);
694
+ const pendingPairing = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/pairing" });
695
+ const pending = (pendingPairing.body as { pending?: { userCode?: string; clientName?: string; clientId?: string; agentCardUrl?: string }[] }).pending ?? [];
696
+ 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);
697
+ const refused = await stub.owner({ accountId: "acct_carol", method: "POST", path: `/owner/pairing/${code}/approve` });
698
+ assert.equal(refused.status, 403);
699
+ const httpSpoof = await mf.dispatchFetch(`https://carol.example.com/owner/pairing/${code}/approve`, {
700
+ method: "POST",
701
+ headers: { authorization: "Bearer owner-carol", "content-type": "application/json", "x-a2a-dashboard-actor": "human" },
702
+ });
703
+ assert.equal(httpSpoof.status, 403);
704
+ const approved = await stub.owner({ accountId: "acct_carol", method: "POST", path: `/owner/pairing/${code}/approve`, actor: "human" });
705
+ assert.equal(approved.status, 200);
706
+ assert.equal((approved.body as { decision?: string }).decision, "approved");
707
+ assert.equal(await saw('"event":"pairing_approved"'), true);
708
+ assert.equal(lines.some((line) => line.includes('"event":"pairing_approved"') && line.includes('"by":"human"')), true);
709
+
710
+ const deniedStart = await mf.dispatchFetch("https://carol.example.com/oauth/device_authorization", {
711
+ method: "POST",
712
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.21" },
713
+ body: "client_name=Deny+Bot&client_id=deny-alias",
714
+ });
715
+ const deniedDevice = await deniedStart.json() as { user_code?: string };
716
+ assert.equal(deniedStart.status, 200);
717
+ const ownerDenyAt = lines.length;
718
+ const ownerDenied = await stub.owner({ accountId: "acct_carol", method: "POST", path: `/owner/pairing/${deniedDevice.user_code}/deny` });
719
+ assert.equal(ownerDenied.status, 200);
720
+ assert.equal(await saw('"event":"pairing_denied"'), true);
721
+ const ownerDenyLine = lines.slice(ownerDenyAt).find((line) => line.includes('"event":"pairing_denied"')) ?? "";
722
+ assert.match(ownerDenyLine, /"by":"owner"/);
723
+ assert.equal(ownerDenyLine.includes('"by":"human"'), false);
724
+
725
+ const humanDenyStart = await mf.dispatchFetch("https://carol.example.com/oauth/device_authorization", {
726
+ method: "POST",
727
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.22" },
728
+ body: "client_name=Human+Deny&client_id=human-deny",
729
+ });
730
+ const humanDenyDevice = await humanDenyStart.json() as { user_code?: string };
731
+ assert.equal(humanDenyStart.status, 200);
732
+ const humanDenyAt = lines.length;
733
+ const humanDenied = await stub.owner({ accountId: "acct_carol", method: "POST", path: `/owner/pairing/${humanDenyDevice.user_code}/deny`, actor: "human" });
734
+ assert.equal(humanDenied.status, 200);
735
+ assert.equal(await sawAfter(humanDenyAt, '"by":"human"'), true);
736
+ assert.equal(lines.slice(humanDenyAt).some((line) => line.includes('"event":"pairing_denied"') && line.includes('"by":"human"')), true);
737
+
738
+ const flagged = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers", actor: "human" });
739
+ assert.equal(flagged.status, 400);
740
+ const badActor = await stub.owner({ accountId: "acct_carol", method: "POST", path: "/owner/pairing/WDJB-4827/approve", actor: "agent" });
741
+ assert.equal(badActor.status, 400);
742
+ assert.equal(await saw('"reason":"actor"'), true);
743
+
605
744
  await stub.pushConfig({ version: 3, tenantId: "id-carol", name: "carol", status: "suspended", ownerTokenHash: hash, config: { AGENT_NAME: "Carol" } });
606
745
  const suspended = await stub.owner({ accountId: "acct_carol", method: "GET", path: "/owner/peers" });
607
746
  assert.equal(suspended.status, 403);
@@ -1,7 +1,7 @@
1
1
  // Owner RPC allowlist and response filter. No Durable Object: the hosted test calls TenantStore.owner.
2
2
  import { test } from "node:test";
3
3
  import assert from "node:assert/strict";
4
- import { accountIdOf, matchOwnerRpc, ownerResponseLeaks } from "../src/owner-rpc.ts";
4
+ import { accountIdOf, matchOwnerRpc, ownerResponseLeaks, scrubOwnerClaims } from "../src/owner-rpc.ts";
5
5
 
6
6
  test("owner RPC allowlist is the management reads and the calls that do not return a secret", () => {
7
7
  const ok = (method: string, path: string, query?: Record<string, string>) => {
@@ -10,6 +10,7 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
10
10
  return m.ok ? m.route : null;
11
11
  };
12
12
  assert.equal(ok("GET", "/owner/inbox")?.name, "GET /owner/inbox");
13
+ assert.equal(ok("GET", "/owner/summary")?.name, "GET /owner/summary");
13
14
  assert.equal(ok("GET", "/owner/inbox", { context: "ctx-1", all: "1" })?.search, "?context=ctx-1&all=1");
14
15
  assert.equal(ok("GET", "/owner/tasks/task-1")?.path, "/owner/tasks/task-1");
15
16
  assert.equal(ok("GET", "/owner/history/ctx-1", { n: "50" })?.search, "?n=50");
@@ -28,13 +29,19 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
28
29
  assert.equal(ok("GET", "/owner/wake/preview")?.name, "GET /owner/wake/preview");
29
30
  assert.equal(ok("POST", "/owner/wake/test")?.name, "POST /owner/wake/test");
30
31
  assert.equal(ok("GET", "/owner/contexts")?.name, "GET /owner/contexts");
32
+ assert.equal(ok("GET", "/owner/log")?.name, "GET /owner/log");
33
+ assert.equal(ok("GET", "/owner/export")?.name, "GET /owner/export");
34
+ assert.equal(ok("GET", "/owner/export", { format: "sql" })?.search, "?format=sql");
31
35
 
32
36
  const refused = (method: string, path: string, query?: Record<string, string>) => matchOwnerRpc(method, path, query);
33
37
  assert.deepEqual(refused("POST", "/owner/peers"), { ok: false, reason: "path" });
34
38
  assert.deepEqual(refused("POST", "/owner/pairing/password-link"), { ok: false, reason: "path" });
35
39
  assert.deepEqual(refused("PUT", "/owner/pairing/password"), { ok: false, reason: "path" });
36
40
  assert.deepEqual(refused("PUT", "/owner/outbound-peers/ada"), { ok: false, reason: "path" });
37
- assert.deepEqual(refused("GET", "/owner/export"), { ok: false, reason: "path" });
41
+ assert.deepEqual(refused("GET", "/owner/export", { format: "json" }), { ok: false, reason: "query" });
42
+ assert.deepEqual(refused("GET", "/owner/export", { format: "sql", n: "1" }), { ok: false, reason: "query" });
43
+ assert.deepEqual(refused("POST", "/owner/export"), { ok: false, reason: "path" });
44
+ assert.deepEqual(refused("GET", "/owner/log", { n: "10" }), { ok: false, reason: "query" });
38
45
  assert.deepEqual(refused("POST", "/owner/purge"), { ok: false, reason: "path" });
39
46
  assert.deepEqual(refused("POST", "/owner/tasks/task-1/reply"), { ok: false, reason: "path" });
40
47
  assert.deepEqual(refused("POST", "/owner/history"), { ok: false, reason: "path" });
@@ -43,6 +50,7 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
43
50
  assert.deepEqual(refused("GET", "/owner/inbox?all=1"), { ok: false, reason: "path" });
44
51
  assert.deepEqual(refused("POST", "/owner/pairing/AEIO-4827/approve"), { ok: false, reason: "path" });
45
52
  assert.deepEqual(refused("GET", "/owner/inbox", { format: "sql" }), { ok: false, reason: "query" });
53
+ assert.deepEqual(refused("GET", "/owner/summary", { all: "1" }), { ok: false, reason: "query" });
46
54
  assert.deepEqual(refused("GET", "/owner/history/ctx-1", { n: "1001" }), { ok: false, reason: "query" });
47
55
  assert.deepEqual(refused("GET", "/owner/history/.."), { ok: false, reason: "path" });
48
56
  assert.equal(accountIdOf("acct_carol"), "acct_carol");
@@ -52,6 +60,7 @@ test("owner RPC allowlist is the management reads and the calls that do not retu
52
60
 
53
61
  test("owner RPC withholds peer tokens and decrypted secrets", () => {
54
62
  const token = "a2aow_" + "a".repeat(43);
63
+ assert.equal(ownerResponseLeaks("a2aot_" + "Q".repeat(43)), true);
55
64
  assert.equal(ownerResponseLeaks({ label: "ada", revoked_at: null }), false);
56
65
  assert.equal(ownerResponseLeaks([{ text: "hello" }]), false);
57
66
  assert.equal(ownerResponseLeaks({ token }), true);
@@ -60,13 +69,46 @@ test("owner RPC withholds peer tokens and decrypted secrets", () => {
60
69
  assert.equal(ownerResponseLeaks({ clientSecret: "tenant-secret-value" }), true);
61
70
  assert.equal(ownerResponseLeaks({ secretsEnc: "v1.ciphertext-value" }), true);
62
71
  assert.equal(ownerResponseLeaks("https://user:pass@hooks.example/wake"), true);
63
- assert.equal(ownerResponseLeaks({ authorization: "Bearer abcd…" }), false);
72
+ assert.equal(ownerResponseLeaks({ method: "POST", headers: { authorization: "Bearer abcd…" } }), true);
73
+ assert.equal(ownerResponseLeaks({ method: "POST", headers: { authorization: "Bearer ••••" } }), false);
74
+ assert.equal(ownerResponseLeaks({ method: "POST", headers: { "x-credential": "••••" } }), false);
64
75
  assert.equal(ownerResponseLeaks({
65
76
  request: { method: "POST", url: "https://hooks.example/wake", headers: { "x-credential": "supersecretvalue", "content-type": "application/json" }, body: "{}" },
66
77
  }), true);
67
78
  assert.equal(ownerResponseLeaks({
68
- request: { method: "POST", url: "https://hooks.example/wake", headers: { "x-credential": "supe…", "content-type": "application/json", "x-request-id": "req-1" }, body: "{}" },
79
+ request: { method: "POST", url: "https://hooks.example/wake", headers: { "x-credential": "Bearer ••••", "content-type": "application/json", "x-request-id": "req-1" }, body: "{}" },
69
80
  }), false);
70
81
  assert.equal(ownerResponseLeaks({ fingerprints: { url: "0123456789ab", key: null } }), false);
71
82
  assert.equal(ownerResponseLeaks({ token_hash: "ab".repeat(32) }), false);
83
+ });
84
+
85
+ test("a token-shaped claim is blanked and the rest of the list is returned", () => {
86
+ const token = "a2aow_" + "a".repeat(20);
87
+ const setup = "https://card.example/card.json?t=" + "c".repeat(16);
88
+ const userinfo = "https://user:secret@card.example/card.json";
89
+ const raw = {
90
+ pending: [
91
+ { userCode: "WDJB-4827", clientName: token, clientId: "barry-alias", agentCardUrl: setup },
92
+ { userCode: "BCDF-2345", clientName: "Barry Bot", clientId: token, agentCardUrl: "https://barry.example/card.json" },
93
+ { client_name: token, agent_card_url: userinfo },
94
+ ],
95
+ token,
96
+ };
97
+ assert.equal(ownerResponseLeaks(raw), true);
98
+ const scrubbed = scrubOwnerClaims(raw) as { pending: { clientName: string; clientId: string; agentCardUrl: string; client_name?: string; agent_card_url?: string }[]; token: string };
99
+ assert.equal(scrubbed.pending[0]!.clientName, "");
100
+ assert.equal(scrubbed.pending[0]!.clientId, "barry-alias");
101
+ assert.equal(scrubbed.pending[0]!.agentCardUrl, "");
102
+ assert.equal(scrubbed.pending[1]!.clientName, "Barry Bot");
103
+ assert.equal(scrubbed.pending[1]!.clientId, "");
104
+ assert.equal(scrubbed.pending[1]!.agentCardUrl, "https://barry.example/card.json");
105
+ assert.equal(scrubbed.pending[2]!.client_name, "");
106
+ assert.equal(scrubbed.pending[2]!.agent_card_url, "");
107
+ assert.equal(scrubbed.token, token);
108
+ assert.equal(ownerResponseLeaks(scrubbed), true, "a real token under another key is still withheld");
109
+ const listed = scrubOwnerClaims({ pending: raw.pending });
110
+ assert.equal(ownerResponseLeaks(listed), false);
111
+ assert.equal(JSON.stringify(listed).includes(token), false);
112
+ assert.equal(JSON.stringify(listed).includes("t=" + "c".repeat(16)), false);
113
+ assert.equal(JSON.stringify(listed).includes("user:secret"), false);
72
114
  });
@@ -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,63 @@ 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
+ });
725
+
726
+ test("owner log lists history without data_json and does not clear a pending wake", async (t) => {
727
+ const s = setup(); t.after(s.restore);
728
+ const issued = await s.owner("POST", "/owner/peers", { label: "ada" });
729
+ assert.match(issued.data.token, /^a2aow_/);
730
+ const pasted = "a2aow_" + "b".repeat(20);
731
+ const owner = "a2aot_" + "Q".repeat(43);
732
+ const sent = await s.call("POST", "/", {
733
+ json: { jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { kind: "message", role: "user", messageId: "m-log", parts: [{ kind: "text", text: "hello from ada" }] } } },
734
+ headers: { authorization: `Bearer ${issued.data.token}` },
735
+ });
736
+ assert.equal(sent.status, 200, JSON.stringify(sent.data));
737
+ const taskId = sent.data.result.id;
738
+ const pending = JSON.stringify({ taskIds: [taskId] });
739
+ const now = Date.now();
740
+ s.DB.db.prepare("INSERT INTO wakes (context_id, last_sent_ms, pending_json, pending_since_ms) VALUES (?, ?, ?, ?)").run("ctx-keep", now, pending, now);
741
+ const secret = JSON.stringify({ token: pasted, messageId: "m-log" });
742
+ s.DB.db.prepare("UPDATE history SET text = ?, data_json = ? WHERE dir = 'in'").run(`hello ${pasted} and ${owner} there`, secret);
743
+ const log = await s.owner("GET", "/owner/log");
744
+ assert.equal(log.status, 200);
745
+ const text = JSON.stringify(log.data);
746
+ assert.match(text, /hello \[redacted\] and \[redacted\] there/);
747
+ assert.equal(text.includes(pasted), false);
748
+ assert.equal(text.includes(owner), false);
749
+ assert.equal(text.includes(secret), false);
750
+ assert.equal(text.includes("data_json"), false);
751
+ assert.equal(text.includes('"data"'), false);
752
+ assert.equal(s.DB.db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").get("ctx-keep")?.pending_json, pending);
753
+ const inbox = await s.owner("GET", "/owner/inbox");
754
+ assert.equal(inbox.status, 200);
755
+ assert.equal(s.DB.db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").get("ctx-keep")?.pending_json, null);
756
+ });