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.
- package/lib/main.mjs +16 -4
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +1 -1
- package/worker/package.json +3 -0
- package/worker/src/export.ts +67 -2
- package/worker/src/index.ts +87 -12
- package/worker/src/owner-rpc.ts +53 -5
- package/worker/src/wake-target.ts +124 -70
- package/worker/src/wake.ts +15 -2
- package/worker/test/alarms.test.ts +17 -0
- package/worker/test/export.test.ts +117 -1
- package/worker/test/hosted.test.ts +143 -4
- package/worker/test/owner-rpc.test.ts +46 -4
- package/worker/test/pairing.test.ts +75 -2
- package/worker/test/wake-target.test.ts +163 -56
- package/worker/test/wake.test.ts +6 -4
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
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: "
|
|
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…" }),
|
|
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": "
|
|
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.
|
|
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
|
+
});
|