@a2a-exposed/control-core 0.8.19 → 0.8.21

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/README.md CHANGED
@@ -10,6 +10,8 @@ Agents that can call out over HTTP and cannot run a public server: hosted assist
10
10
 
11
11
  ## Quick start
12
12
 
13
+ No Cloudflare account, or no preference? Start with a [hosted agent](docs/getting-started.md#hosted-inbox) at `https://<name>.a2a.exposed`. It is in open beta with a Free plan; see [pricing](https://a2a.exposed/pricing).
14
+
13
15
  Claude Code cloud sessions use the [hosted connector](docs/mcp.md#hosted-connector), with no skill install or CLI. Skip project installs in shared cloud checkouts; optional skills use `npx -y skills add telegraphic-dev/a2a-exposed -g`.
14
16
 
15
17
  Self-hosting requires Node 22.18+. The skills are installed into the project the agent works in. They do not install the CLI. Every command runs through npx.
@@ -22,6 +24,8 @@ Self-hosting requires Node 22.18+. The skills are installed into the project the
22
24
 
23
25
  2. **Deploy an A2A endpoint** in your Cloudflare account. [Self-hosting](docs/self-hosting.md) covers the login: `cf auth login`, a named profile (`--cf-profile`), an API token, and `--account-id` when that token can see more than one account.
24
26
 
27
+ Before running `init` or another Cloudflare change, the agent shows you the account name/id from `npx cf auth whoami` and the plan: Worker name, D1 database, hostname and any workers.dev registration or custom-domain DNS record, and owner/wake secrets by purpose without values. It waits for your explicit yes; a login is not consent. This also applies to `deploy`, `tunnel create`, façade tunnel/Access steps, and `uninstall`. See the [consent checklist](skills/a2a-exposed-setup/references/deploy.md#before-cloudflare-changes). Ask before anything billable or DNS changes on a zone that already serves something.
28
+
25
29
  ```bash
26
30
  npx -y a2a-exposed@latest init --hostname agent.example.com --agent-name "My Agent"
27
31
  ```
@@ -41,7 +45,7 @@ After the skill is in the project, you can ask the agent to set this up. It foll
41
45
 
42
46
  ## Hosted
43
47
 
44
- [a2a.exposed](https://a2a.exposed) hosts the A2A endpoint and is open to everyone. Pricing is at [a2a.exposed/pricing](https://a2a.exposed/pricing). When the CLI cannot run, connect https://a2a.exposed/mcp. Sign-in is the owner identity. The owner token stays on the A2A endpoint. Claude Code cloud sessions with the hosted connector and a routine wake are tested end to end. Other Claude clients and Codex are untested on this address. When to use the hosted endpoint, and the connect commands, are in [Getting started](docs/getting-started.md#hosted-inbox).
48
+ [a2a.exposed](https://a2a.exposed/hosted) hosts the A2A endpoint and is open to everyone. Pricing is at [a2a.exposed/pricing](https://a2a.exposed/pricing). Sign in at https://a2a.exposed/app to create an agent; no Cloudflare account is needed. When the CLI cannot run, connect https://a2a.exposed/mcp. Sign-in is the owner identity. The owner token stays on the A2A endpoint. Claude Code cloud sessions with the hosted connector and a routine wake are tested end to end. Other Claude clients and Codex are untested on this address. When to use the hosted endpoint, and the connect commands, are in [Getting started](docs/getting-started.md#hosted-inbox).
45
49
 
46
50
  ## Docs
47
51
 
package/dist/index.js CHANGED
@@ -249,8 +249,8 @@ async function flushOutbox(db, plane, now = Date.now()) {
249
249
  }
250
250
  try {
251
251
  if (await deliverOutbox(db, plane, row)) delivered += 1;
252
- } catch (error) {
253
- console.log(JSON.stringify({ ts: new Date(now).toISOString(), event: "outbox_push_failed", tenant: row.id, error: String(error).slice(0, 200) }));
252
+ } catch {
253
+ console.log(JSON.stringify({ ts: new Date(now).toISOString(), event: "outbox_push_failed", tenant: row.id, error: "config_delivery_failed" }));
254
254
  }
255
255
  }
256
256
  const left = await db.prepare("SELECT COUNT(*) AS n FROM config_outbox").first();
@@ -554,15 +554,17 @@ async function tenantForAccount(db, accountId, name) {
554
554
  }
555
555
  async function saveWakeSettings(db, plane, secretsKey, input) {
556
556
  const row = await tenantForAccount(db, input.accountId, input.name);
557
- if (!row) return { ok: false, error: "conflict" };
557
+ if (!row || input.expectedVersion !== void 0 && input.expectedVersion !== row.config_version) return { ok: false, error: "conflict" };
558
558
  const url = input.url.trim();
559
559
  const preset = input.preset.trim();
560
560
  const keyHeader = input.keyHeader.trim();
561
561
  const key = input.key.trim();
562
+ const hmacSecret = (input.hmacSecret ?? "").trim();
562
563
  if (wakeUrlProblem(url)) return { ok: false, error: "url" };
563
564
  if (!WAKE_PRESETS.includes(preset)) return { ok: false, error: "preset" };
564
565
  if (wakeHeaderProblem(keyHeader)) return { ok: false, error: "header" };
565
- if (key.length > 4096 || /[\u0000-\u001f]/.test(key)) return { ok: false, error: "key" };
566
+ if ([key, hmacSecret].some((value) => value.length > 4096 || /[\u0000-\u001f]/.test(value))) return { ok: false, error: "key" };
567
+ if (url && preset === "openbot" && (key || keyHeader)) return { ok: false, error: keyHeader ? "header" : "key" };
566
568
  const config = configFields(row.config_json);
567
569
  const plaintext = {};
568
570
  for (const secret of WAKE_SECRET_KEYS) {
@@ -570,23 +572,32 @@ async function saveWakeSettings(db, plane, secretsKey, input) {
570
572
  if (value) plaintext[secret] = value;
571
573
  delete config[secret];
572
574
  }
573
- if (url) config.WAKE_WEBHOOK_URL = url;
574
- else delete config.WAKE_WEBHOOK_URL;
575
- config.WAKE_PRESET = preset;
576
- if (keyHeader) config.WAKE_KEY_HEADER = keyHeader;
577
- else delete config.WAKE_KEY_HEADER;
575
+ const same = config.WAKE_WEBHOOK_URL === url && config.WAKE_PRESET === preset;
576
+ const sameKey = same && (config.WAKE_KEY_HEADER ?? "") === keyHeader;
578
577
  let secretsEnc = row.secrets_enc;
579
- const migrate = !secretsEnc && Object.keys(plaintext).length > 0;
580
- if (key || migrate) {
578
+ const hasSecrets = !!secretsEnc || Object.keys(plaintext).length > 0;
579
+ if (hasSecrets || key || hmacSecret || url && preset === "openbot") {
581
580
  const ikm = secretsKey.trim();
582
581
  if (!ikm) return { ok: false, error: "unavailable" };
583
582
  const current = secretsEnc ? await openWakeSecrets(ikm, row.id, secretsEnc) : { ...plaintext };
584
583
  if (!current) return { ok: false, error: "unreadable" };
585
- if (key) current.WAKE_WEBHOOK_KEY = key;
586
- const sealed = await sealWakeSecrets(ikm, row.id, current);
587
- if (!sealed) return { ok: false, error: "unavailable" };
588
- secretsEnc = sealed;
584
+ const before = JSON.stringify(current);
585
+ if (!url || !sameKey || preset === "openbot") delete current.WAKE_WEBHOOK_KEY;
586
+ if (!url || !same) delete current.WAKE_HMAC_SECRET;
587
+ if (url && key && preset !== "openbot") current.WAKE_WEBHOOK_KEY = key;
588
+ if (url && hmacSecret) current.WAKE_HMAC_SECRET = hmacSecret;
589
+ if (url && preset === "openbot" && !current.WAKE_HMAC_SECRET) return { ok: false, error: "key" };
590
+ if (!secretsEnc || JSON.stringify(current) !== before) {
591
+ const sealed = await sealWakeSecrets(ikm, row.id, current);
592
+ if (!sealed) return { ok: false, error: "unavailable" };
593
+ secretsEnc = sealed;
594
+ }
589
595
  }
596
+ if (url) config.WAKE_WEBHOOK_URL = url;
597
+ else delete config.WAKE_WEBHOOK_URL;
598
+ config.WAKE_PRESET = preset;
599
+ if (keyHeader && url) config.WAKE_KEY_HEADER = keyHeader;
600
+ else delete config.WAKE_KEY_HEADER;
590
601
  const version = row.config_version + 1;
591
602
  const now = (/* @__PURE__ */ new Date()).toISOString();
592
603
  const configJson = JSON.stringify(config);
@@ -617,8 +628,8 @@ async function saveWakeSettings(db, plane, secretsKey, input) {
617
628
  console.log(JSON.stringify({ ts: now, event: "wake_settings", tenant: row.id, accountId: input.accountId, preset }));
618
629
  try {
619
630
  await deliverOutbox(db, plane, outbox);
620
- } catch (error) {
621
- console.log(JSON.stringify({ ts: now, event: "outbox_push_failed", tenant: row.id, error: String(error).slice(0, 200) }));
631
+ } catch {
632
+ console.log(JSON.stringify({ ts: now, event: "outbox_push_failed", tenant: row.id, error: "wake_delivery_failed" }));
622
633
  }
623
634
  return { ok: true };
624
635
  }
@@ -3021,6 +3032,9 @@ var conversation_owner_default = "-- One owner per context id. The first peer to
3021
3032
  // sql-raw:/home/runner/work/a2a-exposed/a2a-exposed/worker/migrations/0012_conversation_peer_owner.sql
3022
3033
  var conversation_peer_owner_default = "-- One row per peer. The same context id can be a separate conversation for each peer.\n-- 0011's single-column primary key made a second peer's insert fail, which is how a caller\n-- could tell a fresh id from one another peer already held.\nCREATE TABLE conversation_owners_peer (\n context_id TEXT NOT NULL,\n peer TEXT NOT NULL,\n created_at TEXT NOT NULL,\n PRIMARY KEY (context_id, peer)\n);\nINSERT INTO conversation_owners_peer (context_id, peer, created_at)\nSELECT context_id, peer, created_at FROM conversation_owners;\nDROP TABLE conversation_owners;\nALTER TABLE conversation_owners_peer RENAME TO conversation_owners;\n";
3023
3034
 
3035
+ // sql-raw:/home/runner/work/a2a-exposed/a2a-exposed/worker/migrations/0013_reply_delivery.sql
3036
+ var reply_delivery_default = "-- One delivery outcome for one logical owner reply. The key includes the inbound turn and reply content,\n-- so an HTTP/MCP retry is idempotent while a later peer message permits the same words to be sent again.\nCREATE TABLE IF NOT EXISTS reply_deliveries (\n delivery_key TEXT PRIMARY KEY,\n task_id TEXT NOT NULL,\n message_id TEXT NOT NULL,\n result_json TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS reply_deliveries_task ON reply_deliveries(task_id, created_at);\n";
3037
+
3024
3038
  // src/provision/inbox-migrations.ts
3025
3039
  var INBOX_MIGRATIONS = [
3026
3040
  { name: "0001_init.sql", sql: init_default },
@@ -3034,7 +3048,8 @@ var INBOX_MIGRATIONS = [
3034
3048
  { name: "0009_outbound_seen.sql", sql: outbound_seen_default },
3035
3049
  { name: "0010_conversation_peer.sql", sql: conversation_peer_default },
3036
3050
  { name: "0011_conversation_owner.sql", sql: conversation_owner_default },
3037
- { name: "0012_conversation_peer_owner.sql", sql: conversation_peer_owner_default }
3051
+ { name: "0012_conversation_peer_owner.sql", sql: conversation_peer_owner_default },
3052
+ { name: "0013_reply_delivery.sql", sql: reply_delivery_default }
3038
3053
  ];
3039
3054
 
3040
3055
  // sql-raw:/home/runner/work/a2a-exposed/a2a-exposed/control/migrations/0001_auth.sql
@@ -5,7 +5,7 @@ export type { DeleteResult } from "../tenants/delete.js";
5
5
  export { rotateOwnerToken } from "../tenants/rotate.js";
6
6
  export type { RotateResult } from "../tenants/rotate.js";
7
7
  export { WAKE_PRESETS, configFields, saveWakeSettings, tenantForAccount, wakeHeaderProblem, wakeSettingsOf, wakeUrlProblem, } from "../tenants/wake.js";
8
- export type { WakeSave, WakeSettings } from "../tenants/wake.js";
8
+ export type { WakeSave, WakeSaveInput, WakeSettings } from "../tenants/wake.js";
9
9
  export { MAX_WAKE_PLAIN, MAX_WAKE_SEALED, MAX_WAKE_VALUE, WAKE_SECRET_KEYS, openWakeSecrets, sealWakeSecrets, } from "../tenants/wake-secrets.js";
10
10
  export type { WakeSecretKey, WakeSecrets } from "../tenants/wake-secrets.js";
11
11
  export { RESERVED_NAMES, TENANT_NAME, tenantNameProblem } from "../tenants/reserved.js";
@@ -7,6 +7,19 @@ export interface WakeSettings {
7
7
  preset: string;
8
8
  keyHeader: string;
9
9
  }
10
+ /** Blank credentials retain a saved value only for the same URL and authentication mode.
11
+ * Callers validate public DNS before saving; the Worker enforces its delivery policy separately. */
12
+ export interface WakeSaveInput {
13
+ accountId: string;
14
+ name: string;
15
+ url: string;
16
+ preset: string;
17
+ keyHeader: string;
18
+ key: string;
19
+ hmacSecret?: string;
20
+ /** Version of the settings the caller tested. A stale version makes no write. */
21
+ expectedVersion?: number;
22
+ }
10
23
  export type WakeSave = {
11
24
  ok: true;
12
25
  } | {
@@ -32,12 +45,5 @@ export declare function wakeHeaderProblem(value: string): string;
32
45
  export declare function wakeSettingsOf(config: Record<string, string>): WakeSettings;
33
46
  /** The signed-in account's agent, or null. Another account's agent is the same result. */
34
47
  export declare function tenantForAccount(db: Sql, accountId: string, name: string): Promise<TenantRow | null>;
35
- export declare function saveWakeSettings(db: Sql, plane: DataPlane, secretsKey: string, input: {
36
- accountId: string;
37
- name: string;
38
- url: string;
39
- preset: string;
40
- keyHeader: string;
41
- key: string;
42
- }): Promise<WakeSave>;
48
+ export declare function saveWakeSettings(db: Sql, plane: DataPlane, secretsKey: string, input: WakeSaveInput): Promise<WakeSave>;
43
49
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2a-exposed/control-core",
3
- "version": "0.8.19",
3
+ "version": "0.8.21",
4
4
  "description": "A2A endpoint provisioning, routing, and D1 migrations for a2a-exposed.",
5
5
  "type": "module",
6
6
  "scripts": {