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 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
- 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." : code === "cloudflare-target-unsupported" ? "Use polling or a self-hosted Worker for this Cloudflare-proxied webhook." : "Saved settings were not changed. Check the receiver, credentials and draft, then test again." };
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 (one step)
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 = polling), tunnel state, proxy mode (--upstream):
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 "unknown (owner API not reachable)";
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 "none: no WAKE_WEBHOOK_URL on the Worker, so the agent is expected to poll the A2A endpoint";
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
- if (!s.wake) return done("the hosted endpoint is reachable and its owner credential is accepted. Wake preview is unavailable; check wake delivery through the hosted service.");
298
- return done(s.wake?.configured
299
- ? "none: the hosted endpoint is reachable and its owner credential is accepted. Check wake delivery through the hosted service."
300
- : "the hosted endpoint is reachable. Configure a public HTTPS webhook through the hosted service, or use polling. Cloudflare Tunnel delivery may be unavailable; do not provision a self-hosted tunnel for this address.");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.34",
3
+ "version": "0.8.36",
4
4
  "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: a Cloudflare Worker, a wake webhook, or scheduled checks",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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;
@@ -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
- await wake(ctx, ectx, contextId, task.id, label, preview);
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 key = await ctx.peerKey();
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
- const key = await ctx.peerKey();
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 key = await ctx.peerKey();
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
- const peerKey = await ctx.peerKey();
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 };
@@ -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
- return derivePeerKey(this.#secretsKey, peerKeyInfoForTenant(this.id));
505
+ const material = tenantSecretsMaterials(this.#secretsKey)[0];
506
+ return material ? derivePeerKey(material, peerKeyInfoForTenant(this.id)) : null;
496
507
  }
497
508
  }
498
509
 
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.34";
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
- /** `v1.` + base64(12-byte iv) + `.` + base64(utf-8 plaintext + 16-byte tag). */
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
- /** `v1.<iv>.<ciphertext>`, or null when the key or a value is unusable. */
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 key = await derive(ikm, tenantId);
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(tenantId) },
132
+ { name: "AES-GCM", iv, additionalData: new TextEncoder().encode(aad) },
91
133
  key,
92
134
  encoded,
93
135
  ));
94
- const sealed = `v1.${b64(iv)}.${b64(ct)}`;
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 null;
105
- const key = await derive(ikm, tenantId);
106
- if (!key) return null;
107
- const [version, iv, ct, extra] = sealed.split(".");
108
- if (version !== "v1" || !iv || !ct || extra !== undefined) return null;
109
- try {
110
- const plain = new TextDecoder().decode(await crypto.subtle.decrypt(
111
- { name: "AES-GCM", iv: unb64(iv), additionalData: new TextEncoder().encode(tenantId) },
112
- key,
113
- unb64(ct),
114
- ));
115
- if (plain.length > MAX_WAKE_PLAIN) return null;
116
- const parsed = JSON.parse(plain) as unknown;
117
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
118
- const out: WakeSecrets = {};
119
- for (const name of WAKE_SECRET_KEYS) {
120
- const value = (parsed as Record<string, unknown>)[name];
121
- if (typeof value !== "string" || !value || value.length > MAX_WAKE_VALUE) continue;
122
- out[name] = value;
123
- }
124
- return out;
125
- } catch {
126
- return null;
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
- return { alice, bob, carol, call, owner };
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: "platform-secret" },
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(2, otherTenantBlob)).applied, true);
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
- runOwnerRpc(ctx, ectx as never, { accountId, method, path, ...(body === undefined ? {} : { body }) });
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).structuredContent.status, "stored-only");
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
+ });