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.
- package/lib/main.mjs +5 -2
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +1 -1
- package/worker/package.json +3 -0
- package/worker/src/hosted.ts +46 -8
- package/worker/src/index.ts +92 -8
- package/worker/src/owner-rpc.ts +211 -0
- package/worker/src/tenancy.ts +3 -2
- package/worker/src/wake-secrets.ts +113 -0
- package/worker/src/wake-target.ts +124 -70
- package/worker/src/wake.ts +29 -3
- package/worker/test/alarms.test.ts +17 -0
- package/worker/test/hosted.test.ts +228 -0
- package/worker/test/owner-rpc.test.ts +107 -0
- package/worker/test/pairing.test.ts +43 -2
- package/worker/test/wake-secrets.test.ts +49 -0
- package/worker/test/wake-target.test.ts +163 -56
- package/worker/test/wake.test.ts +20 -2
|
@@ -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.
|
|
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
|
+
});
|