a2a-exposed 0.8.34 → 0.8.36
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/hosted.mjs +12 -1
- package/lib/main.mjs +10 -3
- package/lib/status.mjs +12 -6
- package/package.json +1 -1
- package/worker/src/hosted.ts +0 -3
- package/worker/src/index.ts +19 -11
- package/worker/src/tenancy.ts +12 -1
- package/worker/src/version.ts +1 -1
- package/worker/src/wake-secrets.ts +90 -29
- package/worker/test/address.test.ts +59 -1
- package/worker/test/hosted.test.ts +17 -2
- package/worker/test/owner-mcp.test.ts +11 -0
- package/worker/test/reply-delivery.test.ts +45 -5
- package/worker/test/tenancy.test.ts +17 -0
package/lib/hosted.mjs
CHANGED
|
@@ -194,6 +194,8 @@ export async function status(o = {}) {
|
|
|
194
194
|
|
|
195
195
|
export async function wake(sub, o = {}) {
|
|
196
196
|
if (!["preview", "test", "set", "prepare", "remove", "unset"].includes(sub)) fail("usage", "Use wake preview|test|set|prepare|remove. List presets with hosted capabilities --json.");
|
|
197
|
+
if (sub === "set" && C.deploymentKind() === "hosted" && !o.agent)
|
|
198
|
+
fail("hosted_config", "This is a hosted config. Use dashboard Wake settings or hosted MCP set_wake; the human enters wake secrets on the confirmation page. For scoped CLI setup, run hosted capabilities --json, then hosted list --json to select an explicit --agent NAME before wake set --agent NAME --url URL --preset P --json. No wake settings were changed.");
|
|
197
199
|
const removing = sub === "remove" || sub === "unset";
|
|
198
200
|
const preparingRemoval = sub === "prepare" && o.clear === true;
|
|
199
201
|
if (o.clear && !preparingRemoval) fail("usage", "Use wake prepare --clear for an MCP removal reference, or wake remove --version N. Run wake preview --json to list the current version.");
|
|
@@ -266,7 +268,16 @@ export async function wake(sub, o = {}) {
|
|
|
266
268
|
// Do not print proofs, arbitrary service text, webhook URLs or unexpected fields.
|
|
267
269
|
const code = typeof result.code === "string" && /^(?:[1-5][0-9]{2}|access-40[13]|none|timeout|tls|private|dns|dns-missing|transport-delivery|transport-native|transport-socket|cloudflare-target-unsupported|connection|failed|unreadable|signing)$/.test(result.code) ? result.code : result.saved === true ? "saved" : "failed";
|
|
268
270
|
const ok = result.ok === true && (sub !== "set" || result.saved === true);
|
|
269
|
-
|
|
271
|
+
const recovery = {
|
|
272
|
+
"cloudflare-target-unsupported": "This hosted service cannot deliver to this Cloudflare-proxied webhook. Choose a supported public HTTPS route, polling, or an explicitly authorized self-hosted Worker; do not create another tunnel or weaken Access or hook authentication.",
|
|
273
|
+
"transport-socket": "The hosted service could not connect using its public-HTTPS socket transport. Check hosted route compatibility before changing the receiver. An owner-created Cloudflare Tunnel can pass local checks and still be unreachable by this transport; do not weaken Access or hook authentication.",
|
|
274
|
+
"dns": "The hosted service could not resolve the webhook hostname. Check its public DNS before testing again.",
|
|
275
|
+
"dns-missing": "The hosted service could not resolve the webhook hostname. Check its public DNS before testing again.",
|
|
276
|
+
"tls": "The hosted service could not establish TLS to the webhook. Check the hostname and certificate; keep HTTPS verification enabled.",
|
|
277
|
+
"access-401": "Cloudflare Access refused the test. Check the selected Access service token and policy without disabling Access or hook authentication.",
|
|
278
|
+
"access-403": "Cloudflare Access refused the test. Check the selected Access service token and policy without disabling Access or hook authentication.",
|
|
279
|
+
};
|
|
280
|
+
return { ok, code, saved: result.saved === true, next: ok ? sub === "set" ? "Check the receiver for the test wake." : "The draft passed. Run wake set with the same inputs to test and save." : `Saved settings were not changed. ${recovery[code] || "Check the receiver, credentials and draft, then test again."}` };
|
|
270
281
|
}
|
|
271
282
|
|
|
272
283
|
export async function output(run, o = {}) {
|
package/lib/main.mjs
CHANGED
|
@@ -131,7 +131,9 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
|
|
|
131
131
|
deploy [same flags] redeploy with saved settings (secrets persist); tag the Worker version as a2a-exposed-<version>
|
|
132
132
|
wake set [--preset P] [--agent-id A] [--key-header H] [--key-prefix X] [--body-template JSON]
|
|
133
133
|
[--cli-command CMD] [--debounce S] [--max-per-hour N]
|
|
134
|
-
save wake settings, upload wake secrets from env, and redeploy
|
|
134
|
+
self-hosted: save wake settings, upload wake secrets from env, and redeploy
|
|
135
|
+
hosted config without --agent: stop with dashboard Wake settings / hosted MCP set_wake guidance
|
|
136
|
+
hosted CLI: list hosted capabilities --json and hosted list --json, then select --agent NAME
|
|
135
137
|
openbot: human supplies WAKE_HMAC_SECRET via protected env/secret storage, never chat or argv
|
|
136
138
|
the Worker refuses unsigned openbot wakes (a webhook key is not a signing secret)
|
|
137
139
|
--max-per-hour hourly cap; 0 or unset is unlimited, except Claude Code,
|
|
@@ -191,8 +193,11 @@ Status
|
|
|
191
193
|
read selected hosted agent wake configuration; select NAME with hosted list --json.
|
|
192
194
|
Uses setup approval or that agent's matching owner credential; no delivery test.
|
|
193
195
|
Hosted-only configs report deployment: hosted and hosted recovery guidance.
|
|
196
|
+
Hosted status lists capability/preview next steps; saved wake settings do not prove delivery.
|
|
197
|
+
Offer webhook or polling after checking hook support and hosted route compatibility.
|
|
198
|
+
A listed OpenClaw preset does not prove route reachability; check delivery preflight before tunnel setup.
|
|
194
199
|
status [--json] deployment, Worker version, base URL, agent card check (fetched by the CLI), wake
|
|
195
|
-
mode (webhook / tunnel / none =
|
|
200
|
+
mode (webhook / tunnel / none = no webhook), tunnel state, proxy mode (--upstream):
|
|
196
201
|
upstream card, Access credential and upstream bearer configured (separately), the
|
|
197
202
|
live upstream check, the last failed peer call, card-leak check; warnings for peer
|
|
198
203
|
token variables shadowing saved tokens; and the next step to run.
|
|
@@ -233,7 +238,9 @@ Inbox (owner side)
|
|
|
233
238
|
requests (tell your human; the dashboard URL when MANAGE_URL is set;
|
|
234
239
|
--json: rows on stdout, requests on stderr). Follows every page; past
|
|
235
240
|
10000 pages it errors instead of a short list. Reading marks returned replies
|
|
236
|
-
seen and clears matching pending wakes; task state is unchanged
|
|
241
|
+
seen and clears matching pending wakes; task state is unchanged.
|
|
242
|
+
Direct reply messages are completed without a new-work wake;
|
|
243
|
+
read them with history or inbox --all
|
|
237
244
|
show <taskId> full task JSON
|
|
238
245
|
working <taskId> mark task working
|
|
239
246
|
reply <taskId> (--text T | --stdin) [--state completed|input-required|rejected|failed|working]
|
package/lib/status.mjs
CHANGED
|
@@ -191,9 +191,12 @@ export async function checkCard(base) {
|
|
|
191
191
|
|
|
192
192
|
/** One line describing how wakes reach the agent. */
|
|
193
193
|
export function wakeLine(s) {
|
|
194
|
-
if (!s.wake) return
|
|
194
|
+
if (!s.wake) return s.deployment === "hosted" && s.ownerApi?.ok
|
|
195
|
+
? "unknown (hosted wake preview unavailable)" : "unknown (owner API not reachable)";
|
|
195
196
|
const w = s.wake;
|
|
196
|
-
if (!w.configured) return
|
|
197
|
+
if (!w.configured) return s.deployment === "hosted"
|
|
198
|
+
? "no webhook configured; choose delivery after checking hosted route compatibility"
|
|
199
|
+
: "none: no WAKE_WEBHOOK_URL on the Worker, so the agent is expected to poll the A2A endpoint";
|
|
197
200
|
const auth = [w.hasKey && "bearer/API key", w.hasHmacSecret && "HMAC signature", w.hasAccessServiceToken && "Access service token"].filter(Boolean);
|
|
198
201
|
return `webhook (preset ${w.preset}${s.tunnel ? ", through the tunnel" : ""}); auth: ${auth.join(" + ") || "none"}; URL fingerprint ${w.urlFingerprint || "-"}`;
|
|
199
202
|
}
|
|
@@ -294,10 +297,13 @@ function nextStepCore(s) {
|
|
|
294
297
|
if (s.card.urlProblem) return fail(`the hosted agent card has a different endpoint: ${s.card.urlProblem}. Check the address with the hosted service.`);
|
|
295
298
|
if (!s.health?.ok) return fail("the hosted health check failed. Check the hosted service and run status again.");
|
|
296
299
|
if (!s.ownerApi.ok) return fail("the hosted owner API refused or could not check the saved credential. Check the hosted agent access with its owner.");
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
300
|
+
const discover = `Run \`${CLI} hosted capabilities --json\` to list supported setup scopes and wake presets.`;
|
|
301
|
+
const fallback = "If unavailable, use hosted dashboard Wake settings or MCP set_wake.";
|
|
302
|
+
if (!s.wake) return done(`the hosted endpoint is reachable and its owner credential is accepted. Wake preview is unavailable; delivery is unverified. ${discover}`, [fallback]);
|
|
303
|
+
if (s.wake.configured) return done(`the hosted endpoint is reachable and wake settings are saved; status does not test delivery. ${discover}`, [fallback,
|
|
304
|
+
`When supported, read \`${CLI} wake preview --json\` for the saved agent before an authorized draft test, then verify an inbox-and-reply run.`]);
|
|
305
|
+
return done(`the hosted endpoint is reachable; no webhook is configured. ${discover}`, [fallback,
|
|
306
|
+
"Check hook support and hosted route compatibility, then offer webhook or polling with its interval and cost before configuring either. Do not provision a self-hosted tunnel for this address."]);
|
|
301
307
|
}
|
|
302
308
|
if (!s.deployed) return fail(`nothing is deployed from ${path.dirname(s.configFile)}. Run \`${CLI} init\` (see the quick start). Another deployment? Set A2A_CONFIG_DIR.`);
|
|
303
309
|
if (s.baseUrlEnv)
|
package/package.json
CHANGED
package/worker/src/hosted.ts
CHANGED
|
@@ -163,9 +163,6 @@ export class TenantStore extends DurableObject<WorkerBindings> {
|
|
|
163
163
|
// config_json is ignored, and a blob that cannot be opened leaves those fields unset.
|
|
164
164
|
if (row.secretsEnc) {
|
|
165
165
|
const opened = await openWakeSecrets(parsed.secretsKey || "", row.tenantId, row.secretsEnc);
|
|
166
|
-
if (!opened) {
|
|
167
|
-
console.log(JSON.stringify({ ts: new Date().toISOString(), event: "wake_secrets_unreadable", tenant: row.tenantId }));
|
|
168
|
-
}
|
|
169
166
|
for (const key of WAKE_SECRET_KEYS) {
|
|
170
167
|
const value = opened?.[key];
|
|
171
168
|
if (value) config[key] = value;
|
package/worker/src/index.ts
CHANGED
|
@@ -698,6 +698,7 @@ type Rpc = (p: Json, label: string, version: string, ctx: TenantContext, ectx: E
|
|
|
698
698
|
const rpcSend: Rpc = async (params, label, version, ctx, ectx) => {
|
|
699
699
|
const msg = A.normMessage(params.message);
|
|
700
700
|
if (msg.role !== "user") throw new RpcError(-32602, "message.role must be user");
|
|
701
|
+
const directReply = directReplyParams(params);
|
|
701
702
|
const conf = params.configuration || {};
|
|
702
703
|
const push = conf.taskPushNotificationConfig || conf.pushNotificationConfig;
|
|
703
704
|
const pushCfg = push ? normPushCfg(push, version) : null;
|
|
@@ -718,9 +719,11 @@ const rpcSend: Rpc = async (params, label, version, ctx, ectx) => {
|
|
|
718
719
|
if (!task) task = { id: A.newId(), contextId, status: { state: "submitted", timestamp: ts }, artifacts: [], history: [], _peer: label, _protocol: version, _createdAt: ts, _push: [] };
|
|
719
720
|
msg.contextId = contextId; msg.taskId = task.id;
|
|
720
721
|
task.history.push(msg);
|
|
721
|
-
task.status = { state: "submitted", timestamp: ts };
|
|
722
|
+
task.status = { state: directReply ? "completed" : "submitted", timestamp: ts };
|
|
722
723
|
if (pushCfg) task._push = [...task._push.filter((c: Json) => c.id !== pushCfg.id), pushCfg];
|
|
723
724
|
if (params.metadata && typeof params.metadata === "object") task.metadata = { ...(task.metadata || {}), ...params.metadata };
|
|
725
|
+
// Keep the loop marker on the task even when it was supplied only on the message.
|
|
726
|
+
if (directReply) task.metadata = { ...(task.metadata || {}), [DIRECT_REPLY_META]: params.metadata?.[DIRECT_REPLY_META] || msg.metadata?.[DIRECT_REPLY_META] };
|
|
724
727
|
const preview = A.textOf(msg);
|
|
725
728
|
if (creating) {
|
|
726
729
|
const stored = await ctx.DB.batch([
|
|
@@ -738,7 +741,8 @@ const rpcSend: Rpc = async (params, label, version, ctx, ectx) => {
|
|
|
738
741
|
]);
|
|
739
742
|
}
|
|
740
743
|
log("message_received", { peer: label, contextId, taskId: task.id, chars: preview.length, version });
|
|
741
|
-
|
|
744
|
+
// Direct replies are stored for history, not scheduled as new work.
|
|
745
|
+
if (!directReply) await wake(ctx, ectx, contextId, task.id, label, preview);
|
|
742
746
|
const pt = A.publicTask(task, version, conf.historyLength);
|
|
743
747
|
return version.startsWith("1") ? { task: pt } : pt;
|
|
744
748
|
};
|
|
@@ -1233,14 +1237,21 @@ async function withMovedHeader(ctx: TenantContext, res: Response): Promise<Respo
|
|
|
1233
1237
|
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
1234
1238
|
}
|
|
1235
1239
|
|
|
1240
|
+
/** Every hosted credential sealed by peerKey must retain the previous key during rotation. */
|
|
1241
|
+
async function openPeerSecret(ctx: TenantContext, aad: string, sealed: string): Promise<string | null> {
|
|
1242
|
+
for (const key of await ctx.peerReadKeys()) {
|
|
1243
|
+
const opened = await M.openPeerTokenWithKey(key, aad, sealed);
|
|
1244
|
+
if (opened !== null) return opened;
|
|
1245
|
+
}
|
|
1246
|
+
return null;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1236
1249
|
type PairingIdentity = { publicKey: string; privateKey: string };
|
|
1237
1250
|
|
|
1238
1251
|
async function readIdentity(ctx: TenantContext): Promise<PairingIdentity | null | "unreadable"> {
|
|
1239
1252
|
const row: Json = await ctx.DB.prepare("SELECT value FROM settings WHERE key = ?").bind(Addr.IDENTITY_SETTING).first();
|
|
1240
1253
|
if (!row?.value) return null;
|
|
1241
|
-
const
|
|
1242
|
-
if (!key) return "unreadable";
|
|
1243
|
-
const opened = await M.openPeerTokenWithKey(key, Addr.IDENTITY_AAD, row.value);
|
|
1254
|
+
const opened = await openPeerSecret(ctx, Addr.IDENTITY_AAD, row.value);
|
|
1244
1255
|
if (!opened) return "unreadable";
|
|
1245
1256
|
try {
|
|
1246
1257
|
const parsed = JSON.parse(opened);
|
|
@@ -1411,8 +1422,7 @@ async function planReverse(ctx: TenantContext, alias: string): Promise<{ kind: "
|
|
|
1411
1422
|
async function openReverse(ctx: TenantContext, label: string): Promise<string | null> {
|
|
1412
1423
|
const row: Json = await ctx.DB.prepare("SELECT token_enc, source, revoked_at FROM peers WHERE label = ?").bind(label).first();
|
|
1413
1424
|
if (!row || row.revoked_at || row.source !== "reverse" || !row.token_enc) return null;
|
|
1414
|
-
|
|
1415
|
-
return key ? await M.openPeerTokenWithKey(key, label, row.token_enc) : null;
|
|
1425
|
+
return openPeerSecret(ctx, label, row.token_enc);
|
|
1416
1426
|
}
|
|
1417
1427
|
|
|
1418
1428
|
async function storeReverse(ctx: TenantContext, label: string, token: string, clientName: string, reissue = false): Promise<void> {
|
|
@@ -1544,8 +1554,7 @@ async function replyPeer(ctx: TenantContext, label: string): Promise<{ alias: st
|
|
|
1544
1554
|
WHERE o.alias = ? AND o.token_enc IS NOT NULL AND p.revoked_at IS NULL`,
|
|
1545
1555
|
).bind(label).first();
|
|
1546
1556
|
if (!row) return null;
|
|
1547
|
-
const
|
|
1548
|
-
const token = key ? await M.openPeerTokenWithKey(key, row.alias, row.token_enc) : null;
|
|
1557
|
+
const token = await openPeerSecret(ctx, row.alias, row.token_enc);
|
|
1549
1558
|
return token ? { alias: row.alias, url: row.url, token, identityKey: Addr.cleanPublicKey(row.identity_key), announced: row.source === "announce" } : null;
|
|
1550
1559
|
}
|
|
1551
1560
|
|
|
@@ -3533,8 +3542,7 @@ async function outboundPeer(ctx: TenantContext, alias: string): Promise<{ alias:
|
|
|
3533
3542
|
if (!r) throw new ToolError(`no peer "${alias}" synced to this A2A endpoint. On the machine with the CLI: ${cliCommand(ctx)} peers sync ${alias}`);
|
|
3534
3543
|
let token: string | null = null;
|
|
3535
3544
|
if (r.token_enc) {
|
|
3536
|
-
|
|
3537
|
-
token = peerKey ? await M.openPeerTokenWithKey(peerKey, alias, r.token_enc) : null;
|
|
3545
|
+
token = await openPeerSecret(ctx, alias, r.token_enc);
|
|
3538
3546
|
if (!token) throw new ToolError(`the stored token for "${alias}" can't be read (the owner token was rotated since it was synced): run ${cliCommand(ctx)} peers sync ${alias} again`);
|
|
3539
3547
|
}
|
|
3540
3548
|
return { alias, url: r.url, token };
|
package/worker/src/tenancy.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// and an allowlist are all set (approval-oidc.ts).
|
|
12
12
|
|
|
13
13
|
import * as A from "./a2a.ts";
|
|
14
|
+
import { tenantSecretsMaterials } from "./wake-secrets.ts";
|
|
14
15
|
|
|
15
16
|
/** Subset of D1 used by the handlers. A Durable Object adapter satisfies the same shape. */
|
|
16
17
|
export interface SqlDb {
|
|
@@ -489,10 +490,20 @@ export class TenantContext implements ConfigFields {
|
|
|
489
490
|
return this.#peerKey;
|
|
490
491
|
}
|
|
491
492
|
|
|
493
|
+
/** Read-only keys for hosted outbound tokens during rotation; self-host keeps OWNER_TOKEN semantics. */
|
|
494
|
+
async peerReadKeys(): Promise<CryptoKey[]> {
|
|
495
|
+
const active = await this.peerKey();
|
|
496
|
+
if (!active) return [];
|
|
497
|
+
if (this.mode === "single") return [active];
|
|
498
|
+
const previous = tenantSecretsMaterials(this.#secretsKey || "").slice(1);
|
|
499
|
+
return [active, ...await Promise.all(previous.map((material) => derivePeerKey(material, peerKeyInfoForTenant(this.id))))];
|
|
500
|
+
}
|
|
501
|
+
|
|
492
502
|
async #derivePeerKey(): Promise<CryptoKey | null> {
|
|
493
503
|
if (this.mode === "single") return this.#ownerToken ? derivePeerKey(this.#ownerToken) : null;
|
|
494
504
|
if (!this.#secretsKey || !this.id) return null;
|
|
495
|
-
|
|
505
|
+
const material = tenantSecretsMaterials(this.#secretsKey)[0];
|
|
506
|
+
return material ? derivePeerKey(material, peerKeyInfoForTenant(this.id)) : null;
|
|
496
507
|
}
|
|
497
508
|
}
|
|
498
509
|
|
package/worker/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Inbox build. Keep this equal to `version` in cli/package.json. */
|
|
2
|
-
export const VERSION = "0.8.
|
|
2
|
+
export const VERSION = "0.8.36";
|
|
@@ -36,8 +36,47 @@ export const MAX_WAKE_PLAIN = (() => {
|
|
|
36
36
|
return n;
|
|
37
37
|
})();
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
export const MAX_WAKE_SEALED = 3 + 16 + 1 + Math.ceil((MAX_WAKE_PLAIN * 3 + 16) * 4 / 3);
|
|
39
|
+
/** Longest envelope: `v2.<64-char kid>.<iv>.<ciphertext>`. */
|
|
40
|
+
export const MAX_WAKE_SEALED = 3 + 64 + 1 + 16 + 1 + Math.ceil((MAX_WAKE_PLAIN * 3 + 16) * 4 / 3);
|
|
41
|
+
|
|
42
|
+
/** A bounded keyring lives in the existing secret binding, never in public config. */
|
|
43
|
+
export interface TenantSecretsKeyring {
|
|
44
|
+
active: string;
|
|
45
|
+
keys: Record<string, string>;
|
|
46
|
+
legacy: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Raw keys preserve v1 writes for a reader-first rollout. JSON keyrings enable v2 writes. */
|
|
50
|
+
export function tenantSecretsKeyring(input: string): TenantSecretsKeyring | null {
|
|
51
|
+
if (!input) return null;
|
|
52
|
+
if (!input.trimStart().startsWith("{")) return { active: "", keys: { "": input }, legacy: true };
|
|
53
|
+
if (input.length > 65536) return null;
|
|
54
|
+
try {
|
|
55
|
+
const value = JSON.parse(input);
|
|
56
|
+
if (!value || value.v !== 2 || typeof value.active !== "string" ||
|
|
57
|
+
!value.keys || typeof value.keys !== "object" || Array.isArray(value.keys) ||
|
|
58
|
+
Object.keys(value).some((name) => !["v", "active", "keys"].includes(name))) return null;
|
|
59
|
+
const entries = Object.entries(value.keys);
|
|
60
|
+
if (entries.length < 1 || entries.length > 2 || !Object.hasOwn(value.keys, value.active)) return null;
|
|
61
|
+
for (const [kid, key] of entries) {
|
|
62
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(kid) || typeof key !== "string" || !key.trim() || key.length > 4096) return null;
|
|
63
|
+
}
|
|
64
|
+
return { active: value.active, keys: value.keys, legacy: false };
|
|
65
|
+
} catch { return null; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Current material first, then the single read-only rotation key. No caller may log these values. */
|
|
69
|
+
export function tenantSecretsMaterials(input: string): string[] {
|
|
70
|
+
const ring = tenantSecretsKeyring(input);
|
|
71
|
+
if (!ring) return [];
|
|
72
|
+
return [ring.keys[ring.active], ...Object.entries(ring.keys).filter(([kid]) => kid !== ring.active).map(([, key]) => key)];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function unreadable(): null {
|
|
76
|
+
// Only a fixed event: ciphertext, key ids, credentials and tenant identifiers never enter logs.
|
|
77
|
+
console.error(JSON.stringify({ event: "wake_secrets_unreadable", level: "error" }));
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
41
80
|
|
|
42
81
|
function b64(bytes: Uint8Array): string {
|
|
43
82
|
let raw = "";
|
|
@@ -77,21 +116,24 @@ function payloadOf(secrets: WakeSecrets): Record<string, string> | null {
|
|
|
77
116
|
return payload;
|
|
78
117
|
}
|
|
79
118
|
|
|
80
|
-
/** `
|
|
119
|
+
/** `v2.<kid>.<iv>.<ciphertext>` for keyrings; raw keys keep v1 writes until rollout completes. */
|
|
81
120
|
export async function sealWakeSecrets(ikm: string, tenantId: string, secrets: WakeSecrets): Promise<string | null> {
|
|
82
|
-
const
|
|
121
|
+
const ring = tenantSecretsKeyring(ikm);
|
|
122
|
+
const key = ring ? await derive(ring.keys[ring.active], tenantId) : null;
|
|
83
123
|
const payload = payloadOf(secrets);
|
|
84
124
|
if (!key || !payload) return null;
|
|
85
125
|
const json = JSON.stringify(payload);
|
|
86
126
|
const encoded = new TextEncoder().encode(json);
|
|
87
127
|
if (json.length > MAX_WAKE_PLAIN || encoded.length > MAX_WAKE_PLAIN * 3) return null;
|
|
128
|
+
const prefix = ring!.legacy ? "v1" : `v2.${ring!.active}`;
|
|
129
|
+
const aad = ring!.legacy ? tenantId : `${prefix}\0${tenantId}`;
|
|
88
130
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
89
131
|
const ct = new Uint8Array(await crypto.subtle.encrypt(
|
|
90
|
-
{ name: "AES-GCM", iv, additionalData: new TextEncoder().encode(
|
|
132
|
+
{ name: "AES-GCM", iv, additionalData: new TextEncoder().encode(aad) },
|
|
91
133
|
key,
|
|
92
134
|
encoded,
|
|
93
135
|
));
|
|
94
|
-
const sealed =
|
|
136
|
+
const sealed = `${prefix}.${b64(iv)}.${b64(ct)}`;
|
|
95
137
|
return sealed.length <= MAX_WAKE_SEALED ? sealed : null;
|
|
96
138
|
}
|
|
97
139
|
|
|
@@ -101,28 +143,47 @@ export async function sealWakeSecrets(ikm: string, tenantId: string, secrets: Wa
|
|
|
101
143
|
*/
|
|
102
144
|
export async function openWakeSecrets(ikm: string, tenantId: string, sealed: string | null | undefined): Promise<WakeSecrets | null> {
|
|
103
145
|
if (sealed == null || sealed === "") return {};
|
|
104
|
-
if (sealed.length > MAX_WAKE_SEALED) return
|
|
105
|
-
const
|
|
106
|
-
if (!
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
146
|
+
if (typeof sealed !== "string" || sealed.length > MAX_WAKE_SEALED) return unreadable();
|
|
147
|
+
const ring = tenantSecretsKeyring(ikm);
|
|
148
|
+
if (!ring || !tenantId) return unreadable();
|
|
149
|
+
const parts = sealed.split(".");
|
|
150
|
+
const version = parts[0];
|
|
151
|
+
const kid = version === "v2" ? parts[1] : "";
|
|
152
|
+
if (version !== "v1" && version !== "v2") return unreadable();
|
|
153
|
+
if (parts.length !== (version === "v2" ? 4 : 3)) return unreadable();
|
|
154
|
+
const [iv, ct] = parts.slice(version === "v2" ? 2 : 1);
|
|
155
|
+
if (!iv || !ct || (version === "v2" && (ring.legacy || !Object.hasOwn(ring.keys, kid)))) return unreadable();
|
|
156
|
+
const materials = version === "v2" ? [ring.keys[kid]] : tenantSecretsMaterials(ikm);
|
|
157
|
+
const aad = version === "v2" ? `v2.${kid}\0${tenantId}` : tenantId;
|
|
158
|
+
for (const material of materials) {
|
|
159
|
+
try {
|
|
160
|
+
const key = await derive(material, tenantId);
|
|
161
|
+
if (!key) continue;
|
|
162
|
+
const ivBytes = unb64(iv);
|
|
163
|
+
if (ivBytes.length !== 12) return unreadable();
|
|
164
|
+
const plain = new TextDecoder().decode(await crypto.subtle.decrypt(
|
|
165
|
+
{ name: "AES-GCM", iv: ivBytes, additionalData: new TextEncoder().encode(aad) },
|
|
166
|
+
key,
|
|
167
|
+
unb64(ct),
|
|
168
|
+
));
|
|
169
|
+
if (plain.length > MAX_WAKE_PLAIN) return unreadable();
|
|
170
|
+
const parsed = JSON.parse(plain) as unknown;
|
|
171
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return unreadable();
|
|
172
|
+
const out: WakeSecrets = {};
|
|
173
|
+
for (const name of WAKE_SECRET_KEYS) {
|
|
174
|
+
const value = (parsed as Record<string, unknown>)[name];
|
|
175
|
+
if (typeof value !== "string" || !value || value.length > MAX_WAKE_VALUE) continue;
|
|
176
|
+
out[name] = value;
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
} catch { /* Try the previous key only for an unlabelled v1 blob. */ }
|
|
127
180
|
}
|
|
181
|
+
return unreadable();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Pure maintenance helper. The caller must CAS the original blob and queue the new tenant config. */
|
|
185
|
+
export async function rewrapWakeSecrets(ikm: string, tenantId: string, sealed: string): Promise<string | null> {
|
|
186
|
+
if (!sealed) return null;
|
|
187
|
+
const secrets = await openWakeSecrets(ikm, tenantId, sealed);
|
|
188
|
+
return secrets ? sealWakeSecrets(ikm, tenantId, secrets) : null;
|
|
128
189
|
}
|
|
@@ -7,6 +7,7 @@ import { dispatch, runOwnerRpc } from "../src/index.ts";
|
|
|
7
7
|
import { redactExport, exportRows } from "../src/export.ts";
|
|
8
8
|
import { hostedTenantContext, type DoNamespace, type KvNamespace, type TenantContext, type WorkerBindings } from "../src/tenancy.ts";
|
|
9
9
|
import { d1 } from "./d1.ts";
|
|
10
|
+
import { openPeerTokenWithKey } from "../src/mcp.ts";
|
|
10
11
|
|
|
11
12
|
const MIGRATIONS = new URL("../migrations/", import.meta.url);
|
|
12
13
|
const ectx = { waitUntil(p: Promise<unknown>) { p.catch(() => {}); }, passThroughOnException() {} };
|
|
@@ -70,7 +71,14 @@ async function world(t: { after: (fn: () => void) => void }) {
|
|
|
70
71
|
};
|
|
71
72
|
const owner = (ctx: TenantContext, accountId: string, method: string, path: string, body?: unknown) =>
|
|
72
73
|
runOwnerRpc(ctx, ectx as never, { accountId, method, path, ...(body === undefined ? {} : { body }) });
|
|
73
|
-
|
|
74
|
+
const rotate = async (agent: typeof alice, secretsKey: string) => {
|
|
75
|
+
agent.ctx = hostedTenantContext({ ...env, TENANT_SECRETS_KEY: secretsKey }, {
|
|
76
|
+
id: agent.ctx.id, db: agent.db, publicUrl: agent.ctx.PUBLIC_URL,
|
|
77
|
+
ownerTokenHash: await sha256(agent.ownerToken), status: "active", config: { AGENT_NAME: "fixture" },
|
|
78
|
+
});
|
|
79
|
+
byId.set(agent.ctx.id, agent.ctx);
|
|
80
|
+
};
|
|
81
|
+
return { alice, bob, carol, call, owner, rotate };
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
async function pair(bob: { ctx: TenantContext; ownerToken: string }, call: (ctx: TenantContext, path: string, init?: RequestInit) => Promise<{ status: number; data: any }>, identity?: string) {
|
|
@@ -474,3 +482,53 @@ test("export omits the sealed pairing identity and a stored return token", async
|
|
|
474
482
|
assert.equal(JSON.stringify(redacted).includes(ident.privateKey), false);
|
|
475
483
|
assert.equal(JSON.stringify(redacted).includes("sealed-return"), false);
|
|
476
484
|
});
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
test("hosted key rotation preserves identity proofs, reverse tokens and direct replies", async (t) => {
|
|
488
|
+
const { alice, carol, call, owner, rotate } = await world(t as never);
|
|
489
|
+
const ident = await Addr.generateIdentity();
|
|
490
|
+
const putIdentity = () => call(alice.ctx, "/owner/identity", {
|
|
491
|
+
method: "PUT", headers: { authorization: `Bearer ${alice.ownerToken}`, "content-type": "application/json" },
|
|
492
|
+
body: JSON.stringify(ident),
|
|
493
|
+
});
|
|
494
|
+
assert.equal((await putIdentity()).status, 200);
|
|
495
|
+
const paired = await pair(carol, call, ident.publicKey);
|
|
496
|
+
assert.equal((await owner(alice.ctx, "acct-alice", "PUT", `/owner/outbound-peers/${paired.label}`, { url: carol.ctx.PUBLIC_URL, token: paired.access })).status, 200);
|
|
497
|
+
const announce = () => owner(alice.ctx, "acct-alice", "POST", "/owner/peers/announce", { to: paired.label });
|
|
498
|
+
const firstAnnouncement = await announce();
|
|
499
|
+
assert.equal(firstAnnouncement.body.results[0].status, "updated", JSON.stringify(firstAnnouncement.body));
|
|
500
|
+
const reverseBefore = await alice.db.prepare("SELECT token_hash, token_enc FROM peers WHERE label = ? AND source = 'reverse'").bind(paired.label).first() as { token_hash: string; token_enc: string };
|
|
501
|
+
assert.ok(reverseBefore.token_enc);
|
|
502
|
+
const identityBefore = await alice.db.prepare("SELECT value FROM settings WHERE key = ?").bind(Addr.IDENTITY_SETTING).first() as { value: string };
|
|
503
|
+
const ring = JSON.stringify({ v: 2, active: "current", keys: { previous: "platform-key", current: "new-platform-key" } });
|
|
504
|
+
await rotate(alice, ring);
|
|
505
|
+
await rotate(carol, ring);
|
|
506
|
+
const cardUrl = Addr.cardUrlFor(alice.ctx.PUBLIC_URL);
|
|
507
|
+
const card = await call(alice.ctx, "/.well-known/agent-card.json");
|
|
508
|
+
assert.equal(await Addr.cardProves(card.data, ident.publicKey, cardUrl), true, "the stored old-key identity still signs the card");
|
|
509
|
+
assert.deepEqual(await alice.db.prepare("SELECT value FROM settings WHERE key = ?").bind(Addr.IDENTITY_SETTING).first(), identityBefore, "a public read never rewrites identity storage");
|
|
510
|
+
const taskId = await send(carol.ctx, paired.access, "rotation question", "ctx-rotation");
|
|
511
|
+
const reply = await owner(carol.ctx, "acct-carol", "POST", "/owner/mcp/reply", { taskId, text: "rotation answer", state: "completed" });
|
|
512
|
+
assert.equal(reply.body.structuredContent.status, "delivered", JSON.stringify(reply.body));
|
|
513
|
+
assert.equal((await announce()).body.results[0].status, "updated", "the old-key identity and outbound token remain usable");
|
|
514
|
+
const reverseAfter = await alice.db.prepare("SELECT token_hash, token_enc FROM peers WHERE label = ? AND source = 'reverse'").bind(paired.label).first() as typeof reverseBefore;
|
|
515
|
+
assert.equal(reverseAfter.token_hash, reverseBefore.token_hash, "rotation must preserve the existing return token");
|
|
516
|
+
assert.notEqual(reverseAfter.token_enc, reverseBefore.token_enc);
|
|
517
|
+
assert.ok(await openPeerTokenWithKey((await alice.ctx.peerKey())!, paired.label, reverseAfter.token_enc), "authorized announcement reseals the same reverse token with the active key");
|
|
518
|
+
// Existing owner-only identity installation reseals the SAME validated identity; it never regenerates it.
|
|
519
|
+
const unauthorized = await call(alice.ctx, "/owner/identity", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(ident) });
|
|
520
|
+
assert.equal(unauthorized.status, 401);
|
|
521
|
+
const resealed = await putIdentity();
|
|
522
|
+
assert.equal(resealed.status, 200);
|
|
523
|
+
assert.equal(resealed.data.publicKey, ident.publicKey);
|
|
524
|
+
assert.equal(JSON.stringify(resealed.data).includes(ident.privateKey), false);
|
|
525
|
+
const onlyCurrent = JSON.stringify({ v: 2, active: "current", keys: { current: "new-platform-key" } });
|
|
526
|
+
await rotate(alice, onlyCurrent);
|
|
527
|
+
const retiredCard = await call(alice.ctx, "/.well-known/agent-card.json");
|
|
528
|
+
assert.equal(await Addr.cardProves(retiredCard.data, ident.publicKey, cardUrl), true, "owner reseal keeps the identity readable after retirement");
|
|
529
|
+
const identityAfter = await alice.db.prepare("SELECT value FROM settings WHERE key = ?").bind(Addr.IDENTITY_SETTING).first() as { value: string };
|
|
530
|
+
assert.notEqual(identityAfter.value, identityBefore.value);
|
|
531
|
+
const other = await Addr.generateIdentity();
|
|
532
|
+
const replacement = await call(alice.ctx, "/owner/identity", { method: "PUT", headers: { authorization: `Bearer ${alice.ownerToken}`, "content-type": "application/json" }, body: JSON.stringify(other) });
|
|
533
|
+
assert.equal(replacement.status, 409, "rotation never authorizes replacing the identity");
|
|
534
|
+
});
|
|
@@ -933,8 +933,10 @@ test("self-host workerd: human mode owner token approves, the device poll return
|
|
|
933
933
|
|
|
934
934
|
test("sealed upstream and wake Access credentials reach only their tenant context in workerd", async (t) => {
|
|
935
935
|
const received: { origin: string; bearer: string | null; id: string | null; secret: string | null }[] = [];
|
|
936
|
+
const keys = { previous: "platform-secret", current: "next-platform-secret" };
|
|
937
|
+
const keyring = (active: string) => JSON.stringify({ v: 2, active, keys });
|
|
936
938
|
const mf = await start({
|
|
937
|
-
bindings: { TENANCY: "host", TENANT_DOMAIN: "example.com", TENANT_SECRETS_KEY: "
|
|
939
|
+
bindings: { TENANCY: "host", TENANT_DOMAIN: "example.com", TENANT_SECRETS_KEY: keyring("current") },
|
|
938
940
|
durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
|
|
939
941
|
outboundService: async (request: Request) => {
|
|
940
942
|
received.push({ origin: new URL(request.url).origin, bearer: request.headers.get("authorization"), id: request.headers.get("cf-access-client-id"), secret: request.headers.get("cf-access-client-secret") });
|
|
@@ -966,9 +968,22 @@ test("sealed upstream and wake Access credentials reach only their tenant contex
|
|
|
966
968
|
assert.equal(preview.status, 200);
|
|
967
969
|
assert.equal((preview.body as { hasAccessServiceToken: boolean }).hasAccessServiceToken, true);
|
|
968
970
|
for (const value of Object.values(secrets)) assert.equal(JSON.stringify([facade.body, preview.body]).includes(value), false);
|
|
971
|
+
let version = 1;
|
|
972
|
+
for (const active of ["previous", "current"]) {
|
|
973
|
+
const v2 = await sealWakeSecrets(keyring(active), "sealed-tenant", secrets);
|
|
974
|
+
assert.ok(v2?.startsWith(`v2.${active}.`));
|
|
975
|
+
assert.equal((await push(++version, v2)).applied, true);
|
|
976
|
+
const facadeV2 = await stub.owner({ accountId: "dummy-owner", method: "GET", path: "/owner/facade" });
|
|
977
|
+
assert.equal((facadeV2.body as { hasUpstreamToken: boolean }).hasUpstreamToken, true);
|
|
978
|
+
assert.equal(received.at(-1)?.bearer, "Bearer dummy-bearer");
|
|
979
|
+
const wakeV2 = await stub.owner({ accountId: "dummy-owner", method: "GET", path: "/owner/wake/preview" });
|
|
980
|
+
assert.equal((wakeV2.body as { hasAccessServiceToken: boolean }).hasAccessServiceToken, true);
|
|
981
|
+
for (const value of Object.values(secrets)) assert.equal(JSON.stringify([facadeV2.body, wakeV2.body]).includes(value), false);
|
|
982
|
+
}
|
|
983
|
+
|
|
969
984
|
const otherTenantBlob = await sealWakeSecrets("platform-secret", "another-tenant", secrets);
|
|
970
985
|
assert.ok(otherTenantBlob);
|
|
971
|
-
assert.equal((await push(
|
|
986
|
+
assert.equal((await push(++version, otherTenantBlob)).applied, true);
|
|
972
987
|
const rejected = await stub.owner({ accountId: "dummy-owner", method: "GET", path: "/owner/facade" });
|
|
973
988
|
assert.equal((rejected.body as { hasUpstreamToken: boolean }).hasUpstreamToken, false);
|
|
974
989
|
assert.equal((rejected.body as { hasUpstreamAccessServiceToken: boolean }).hasUpstreamAccessServiceToken, false);
|
|
@@ -95,6 +95,17 @@ test("hosted control plane send, reply, and mark_working", async (t) => {
|
|
|
95
95
|
assert.equal(viaHttp.text.includes("tok-alpha"), false);
|
|
96
96
|
assert.equal(peerCalls.at(-1)!.body.params.message.parts[0].text, "from the owner token");
|
|
97
97
|
|
|
98
|
+
// A hosted rotation keeps stored outbound tokens readable through the actual owner send path.
|
|
99
|
+
const rotating = hostedTenantContext({ ...env, TENANT_SECRETS_KEY: JSON.stringify({
|
|
100
|
+
v: 2, active: "next", keys: { old: "platform-key", next: "next-platform-key" },
|
|
101
|
+
}) }, { id: "ten_carol", db: DB, publicUrl: BASE, ownerTokenHash: hash, status: "active" });
|
|
102
|
+
const afterRotation = await runOwnerRpc(rotating, ectx as any, {
|
|
103
|
+
accountId: "acct_carol", method: "POST", path: "/owner/mcp/send", body: { to: "alpha", text: "after rotation" },
|
|
104
|
+
});
|
|
105
|
+
assert.equal(afterRotation.status, 200);
|
|
106
|
+
assert.equal((afterRotation.body as any).isError, false, JSON.stringify(afterRotation.body));
|
|
107
|
+
assert.equal(peerCalls.at(-1)!.auth, "Bearer tok-alpha");
|
|
108
|
+
|
|
98
109
|
const withBody = await runOwnerRpc(ctx, ectx as any, { accountId: "acct_carol", method: "GET", path: "/owner/peers", body: { label: "bob" } });
|
|
99
110
|
assert.equal(withBody.status, 400);
|
|
100
111
|
assert.equal((withBody.body as { error?: string }).error, "this call takes no body");
|
|
@@ -55,8 +55,10 @@ async function fixture(t: Parameters<typeof test>[1] extends (t: infer T) => unk
|
|
|
55
55
|
byId.set("id-alice", a); byId.set("id-bob", b);
|
|
56
56
|
store.set("tenant:alice", JSON.stringify({ id: "id-alice", status: "active", region: "default", version: 1 }));
|
|
57
57
|
store.set("tenant:bob", JSON.stringify({ id: "id-bob", status: "active", region: "default", version: 1 }));
|
|
58
|
-
const owner = (ctx: TenantContext, accountId: string, method: string, path: string, body?: unknown) =>
|
|
59
|
-
|
|
58
|
+
const owner = (ctx: TenantContext, accountId: string, method: string, path: string, body?: unknown) => {
|
|
59
|
+
const url = new URL(path, ctx.PUBLIC_URL);
|
|
60
|
+
return runOwnerRpc(ctx, ectx as never, { accountId, method, path: url.pathname, query: Object.fromEntries(url.searchParams), ...(body === undefined ? {} : { body }) });
|
|
61
|
+
};
|
|
60
62
|
const issue = async (ctx: TenantContext, ownerToken: string, label: string) => {
|
|
61
63
|
const r = await dispatch(new Request(ctx.PUBLIC_URL + "/owner/peers", { method: "POST", headers: { authorization: `Bearer ${ownerToken}`, "content-type": "application/json" }, body: JSON.stringify({ label }) }), ctx, ectx as never);
|
|
62
64
|
return (await r.json() as { token: string }).token;
|
|
@@ -71,7 +73,7 @@ async function fixture(t: Parameters<typeof test>[1] extends (t: infer T) => unk
|
|
|
71
73
|
assert.equal(r.status, 200, JSON.stringify(data));
|
|
72
74
|
return data.result.id as string;
|
|
73
75
|
};
|
|
74
|
-
return { a, b, dbA, dbB, owner, sendToBob, cards, missingCard() { missingCard = true; }, get directPosts() { return directPosts; }, get aborted() { return aborted; }, blockDirect() {
|
|
76
|
+
return { a, b, dbA, dbB, owner, sendToBob, bobAtAlice, otherAtAlice: () => issue(a, ownerA, "other"), cards, missingCard() { missingCard = true; }, get directPosts() { return directPosts; }, get aborted() { return aborted; }, blockDirect() {
|
|
75
77
|
let release!: () => void;
|
|
76
78
|
hold = new Promise<void>((resolve) => { release = resolve; });
|
|
77
79
|
const started = new Promise<void>((resolve) => { posted = resolve; });
|
|
@@ -91,15 +93,20 @@ test("reply delivers once to an exact paired peer in the same context and a dire
|
|
|
91
93
|
assert.equal(JSON.stringify(first.body).includes("a2aow_"), false);
|
|
92
94
|
assert.equal(s.directPosts, 1);
|
|
93
95
|
|
|
94
|
-
const inbox = await s.owner(s.a, "acct-alice", "GET", "/owner/inbox");
|
|
96
|
+
const inbox = await s.owner(s.a, "acct-alice", "GET", "/owner/inbox?all=1");
|
|
95
97
|
const delivered = (inbox.body as any[]).find((row) => row.text === "answer");
|
|
96
98
|
assert.equal(delivered.contextId, "ctx-direct");
|
|
99
|
+
assert.equal(delivered.state, "completed");
|
|
100
|
+
assert.deepEqual((await s.dbA.prepare("SELECT * FROM wakes").all()).results, []);
|
|
101
|
+
const openInbox = await s.owner(s.a, "acct-alice", "GET", "/owner/inbox");
|
|
102
|
+
assert.deepEqual(openInbox.body, []);
|
|
97
103
|
const stored = await s.dbA.prepare("SELECT metadata_json FROM tasks WHERE id = ?").bind(delivered.taskId).first() as { metadata_json?: string };
|
|
98
104
|
const metadata = JSON.parse(stored.metadata_json || "{}");
|
|
99
105
|
assert.deepEqual(metadata["a2a.exposed/reply-delivery"].taskId, taskId);
|
|
100
106
|
|
|
101
107
|
const noLoop = await s.owner(s.a, "acct-alice", "POST", "/owner/mcp/reply", { taskId: delivered.taskId, text: "ack", state: "completed" });
|
|
102
|
-
assert.equal((noLoop.body as any).
|
|
108
|
+
assert.equal((noLoop.body as any).isError, true);
|
|
109
|
+
assert.match(String((noLoop.body as any).text), /already completed/);
|
|
103
110
|
assert.equal(s.directPosts, 1, "a directly delivered reply is never delivered back");
|
|
104
111
|
|
|
105
112
|
const retry = await s.owner(s.b, "acct-bob", "POST", "/owner/mcp/reply", { taskId, text: "answer", state: "completed" });
|
|
@@ -219,3 +226,36 @@ test("reply discovers one canonical card and uses the paired base when it is mis
|
|
|
219
226
|
assert.deepEqual(s.cards, ["/.well-known/agent-card.json"]);
|
|
220
227
|
assert.equal(s.directPosts, 1);
|
|
221
228
|
});
|
|
229
|
+
|
|
230
|
+
for (const version of ["0.3", "1.0"]) {
|
|
231
|
+
for (const placement of ["request", "message"]) {
|
|
232
|
+
test(`direct reply metadata on ${placement} auto-completes without waking (${version})`, async (t) => {
|
|
233
|
+
const s = await fixture(t as never);
|
|
234
|
+
const contextId = "ctx-marker";
|
|
235
|
+
const marker = { "a2a.exposed/reply-delivery": { taskId: "remote-task", messageId: "remote-message", state: "working" } };
|
|
236
|
+
const send = async (metadata: unknown, taskId?: string, token = s.bobAtAlice) => {
|
|
237
|
+
const message = { role: version === "1.0" ? "ROLE_USER" : "user", messageId: crypto.randomUUID(), contextId,
|
|
238
|
+
parts: [{ kind: "text", text: "reply text" }], ...(taskId ? { taskId } : {}), ...(placement === "message" ? { metadata } : {}) };
|
|
239
|
+
const r = await dispatch(new Request(s.a.PUBLIC_URL, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
240
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: "marker", method: version === "1.0" ? "SendMessage" : "message/send", params: { message, ...(placement === "request" ? { metadata } : {}) } }) }), s.a, ectx as never);
|
|
241
|
+
return await r.json() as any;
|
|
242
|
+
};
|
|
243
|
+
const result = await send(marker);
|
|
244
|
+
const task = version === "1.0" ? result.result.task : result.result;
|
|
245
|
+
assert.equal(task.status.state, version === "1.0" ? "TASK_STATE_COMPLETED" : "completed");
|
|
246
|
+
assert.deepEqual(task.metadata, marker);
|
|
247
|
+
assert.deepEqual((await s.owner(s.a, "acct-alice", "GET", "/owner/inbox")).body, []);
|
|
248
|
+
assert.deepEqual((await s.dbA.prepare("SELECT * FROM wakes").all()).results, []);
|
|
249
|
+
const history = await s.owner(s.a, "acct-alice", "GET", `/owner/history/${contextId}?from=bob`);
|
|
250
|
+
assert.match(JSON.stringify(history.body), /reply text/);
|
|
251
|
+
const unauthorized = await send(marker, task.id, await s.otherAtAlice());
|
|
252
|
+
assert.equal(unauthorized.error.code, -32001);
|
|
253
|
+
for (const metadata of [{ other: {} }, { "a2a.exposed/reply-delivery": "completed" }]) {
|
|
254
|
+
const normal = await send(metadata);
|
|
255
|
+
assert.equal((version === "1.0" ? normal.result.task : normal.result).status.state, version === "1.0" ? "TASK_STATE_SUBMITTED" : "submitted");
|
|
256
|
+
}
|
|
257
|
+
assert.equal(((await s.owner(s.a, "acct-alice", "GET", "/owner/inbox")).body as any[]).length, 2);
|
|
258
|
+
assert.equal((await s.dbA.prepare("SELECT COUNT(*) AS n FROM wakes").first() as any).n, 1);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -268,3 +268,20 @@ test("fetch path: gates set or unset, the self-host Worker still answers from OW
|
|
|
268
268
|
const no = await call({ ...base, ...gates }, "/owner/inbox", { authorization: "Bearer platform-key" });
|
|
269
269
|
assert.equal(no.status, 401);
|
|
270
270
|
});
|
|
271
|
+
|
|
272
|
+
test("hosted peer token keys follow the active ring key and keep the previous key for reads", async () => {
|
|
273
|
+
const make = (key: string, id = "ten_a") => hostedTenantContext(selfEnv({ TENANT_SECRETS_KEY: key }), { id, db: dummy, publicUrl: BASE });
|
|
274
|
+
const old = make("old-platform-key");
|
|
275
|
+
const ring = JSON.stringify({ v: 2, active: "current", keys: { previous: "old-platform-key", current: "new-platform-key" } });
|
|
276
|
+
const rotating = make(ring);
|
|
277
|
+
const oldToken = await sealPeerTokenWithKey((await old.peerKey())!, "alpha", "fixture-old-token");
|
|
278
|
+
const keys = await rotating.peerReadKeys();
|
|
279
|
+
assert.equal(keys.length, 2);
|
|
280
|
+
assert.equal(await openPeerTokenWithKey(keys[0], "alpha", oldToken), null);
|
|
281
|
+
assert.equal(await openPeerTokenWithKey(keys[1], "alpha", oldToken), "fixture-old-token");
|
|
282
|
+
const newToken = await sealPeerTokenWithKey((await rotating.peerKey())!, "alpha", "fixture-new-token");
|
|
283
|
+
assert.equal(await openPeerTokenWithKey((await make("new-platform-key").peerKey())!, "alpha", newToken), "fixture-new-token");
|
|
284
|
+
for (const key of await make(ring, "ten_b").peerReadKeys()) assert.equal(await openPeerTokenWithKey(key, "alpha", oldToken), null);
|
|
285
|
+
assert.deepEqual(await make('{"v":2}').peerReadKeys(), []);
|
|
286
|
+
assert.equal((await resolveTenant(selfEnv()).peerReadKeys()).length, 1);
|
|
287
|
+
});
|