@a2a-exposed/control-core 0.8.18 → 0.8.20

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,7 +10,11 @@ Agents that can call out over HTTP and cannot run a public server: hosted assist
10
10
 
11
11
  ## Quick start
12
12
 
13
- 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.
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
+
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`.
16
+
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.
14
18
 
15
19
  1. **Add the skills to the project**
16
20
 
@@ -20,6 +24,8 @@ Requires Node 22.18+. The skills are installed into the project the agent works
20
24
 
21
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.
22
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
+
23
29
  ```bash
24
30
  npx -y a2a-exposed@latest init --hostname agent.example.com --agent-name "My Agent"
25
31
  ```
@@ -35,11 +41,11 @@ Requires Node 22.18+. The skills are installed into the project the agent works
35
41
 
36
42
  `status` is read-only and prints the next step. Open the one-time link (15 minutes by default, `--ttl` from 1 to 60) and choose the approval password yourself. Another agent then runs `npx -y a2a-exposed@latest connect https://agent.example.com` (the code lasts 60 minutes). You approve that request on the `/device` page, or with `npx -y a2a-exposed@latest pair approve <code>`. Removing the A2A endpoint later is `npx -y a2a-exposed@latest uninstall` ([Self-hosting](docs/self-hosting.md#remove-an-inbox)).
37
43
 
38
- After the skill is in the project, you can ask the agent to set this up. It follows the setup skill. Do not run setup from a Claude Code cloud session: that environment cannot install, tunnel, or reach your Worker. Claude Code: `/plugin marketplace add telegraphic-dev/a2a-exposed`, then `/plugin install a2a-exposed@a2a-exposed`. [Getting started](docs/getting-started.md) has Node, the client table, and what to do if setup stops halfway.
44
+ After the skill is in the project, you can ask the agent to set this up. It follows the setup skill. Claude Code cloud sessions follow the [hosted checklist](docs/mcp.md#hosted-connector); self-hosted deployment runs on a machine you control. Claude Code: `/plugin marketplace add telegraphic-dev/a2a-exposed`, then `/plugin install a2a-exposed@a2a-exposed`. [Getting started](docs/getting-started.md) has Node, the client table, and what to do if setup stops halfway.
39
45
 
40
46
  ## Hosted
41
47
 
42
- [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 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).
43
49
 
44
50
  ## Docs
45
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();
@@ -498,14 +498,16 @@ async function listTenants(db, accountId, domain) {
498
498
  }
499
499
 
500
500
  // src/tenants/wake.ts
501
- var WAKE_PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "generic"];
501
+ var WAKE_PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "openbot", "generic"];
502
502
  var PLAIN_HEADERS = /* @__PURE__ */ new Set([
503
503
  "content-type",
504
504
  "user-agent",
505
505
  "x-request-id",
506
506
  "idempotency-key",
507
507
  "anthropic-version",
508
- "x-webhook-timestamp"
508
+ "x-webhook-timestamp",
509
+ "x-openbot-timestamp",
510
+ "x-openbot-delivery-id"
509
511
  ]);
510
512
  var HEADER_NAME = /^[A-Za-z0-9-]{1,64}$/;
511
513
  function configFields(text) {
@@ -552,15 +554,17 @@ async function tenantForAccount(db, accountId, name) {
552
554
  }
553
555
  async function saveWakeSettings(db, plane, secretsKey, input) {
554
556
  const row = await tenantForAccount(db, input.accountId, input.name);
555
- if (!row) return { ok: false, error: "conflict" };
557
+ if (!row || input.expectedVersion !== void 0 && input.expectedVersion !== row.config_version) return { ok: false, error: "conflict" };
556
558
  const url = input.url.trim();
557
559
  const preset = input.preset.trim();
558
560
  const keyHeader = input.keyHeader.trim();
559
561
  const key = input.key.trim();
562
+ const hmacSecret = (input.hmacSecret ?? "").trim();
560
563
  if (wakeUrlProblem(url)) return { ok: false, error: "url" };
561
564
  if (!WAKE_PRESETS.includes(preset)) return { ok: false, error: "preset" };
562
565
  if (wakeHeaderProblem(keyHeader)) return { ok: false, error: "header" };
563
- 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" };
564
568
  const config = configFields(row.config_json);
565
569
  const plaintext = {};
566
570
  for (const secret of WAKE_SECRET_KEYS) {
@@ -568,23 +572,32 @@ async function saveWakeSettings(db, plane, secretsKey, input) {
568
572
  if (value) plaintext[secret] = value;
569
573
  delete config[secret];
570
574
  }
571
- if (url) config.WAKE_WEBHOOK_URL = url;
572
- else delete config.WAKE_WEBHOOK_URL;
573
- config.WAKE_PRESET = preset;
574
- if (keyHeader) config.WAKE_KEY_HEADER = keyHeader;
575
- 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;
576
577
  let secretsEnc = row.secrets_enc;
577
- const migrate = !secretsEnc && Object.keys(plaintext).length > 0;
578
- if (key || migrate) {
578
+ const hasSecrets = !!secretsEnc || Object.keys(plaintext).length > 0;
579
+ if (hasSecrets || key || hmacSecret || url && preset === "openbot") {
579
580
  const ikm = secretsKey.trim();
580
581
  if (!ikm) return { ok: false, error: "unavailable" };
581
582
  const current = secretsEnc ? await openWakeSecrets(ikm, row.id, secretsEnc) : { ...plaintext };
582
583
  if (!current) return { ok: false, error: "unreadable" };
583
- if (key) current.WAKE_WEBHOOK_KEY = key;
584
- const sealed = await sealWakeSecrets(ikm, row.id, current);
585
- if (!sealed) return { ok: false, error: "unavailable" };
586
- 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
+ }
587
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;
588
601
  const version = row.config_version + 1;
589
602
  const now = (/* @__PURE__ */ new Date()).toISOString();
590
603
  const configJson = JSON.stringify(config);
@@ -615,8 +628,8 @@ async function saveWakeSettings(db, plane, secretsKey, input) {
615
628
  console.log(JSON.stringify({ ts: now, event: "wake_settings", tenant: row.id, accountId: input.accountId, preset }));
616
629
  try {
617
630
  await deliverOutbox(db, plane, outbox);
618
- } catch (error) {
619
- 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" }));
620
633
  }
621
634
  return { ok: true };
622
635
  }
@@ -3019,6 +3032,9 @@ var conversation_owner_default = "-- One owner per context id. The first peer to
3019
3032
  // sql-raw:/home/runner/work/a2a-exposed/a2a-exposed/worker/migrations/0012_conversation_peer_owner.sql
3020
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";
3021
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
+
3022
3038
  // src/provision/inbox-migrations.ts
3023
3039
  var INBOX_MIGRATIONS = [
3024
3040
  { name: "0001_init.sql", sql: init_default },
@@ -3032,7 +3048,8 @@ var INBOX_MIGRATIONS = [
3032
3048
  { name: "0009_outbound_seen.sql", sql: outbound_seen_default },
3033
3049
  { name: "0010_conversation_peer.sql", sql: conversation_peer_default },
3034
3050
  { name: "0011_conversation_owner.sql", sql: conversation_owner_default },
3035
- { 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 }
3036
3053
  ];
3037
3054
 
3038
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";
@@ -1,12 +1,25 @@
1
1
  import type { Sql } from "../core/sql.js";
2
2
  import { type DataPlane } from "./push.js";
3
3
  /** The same list the Worker renders. Claude Code is one of them. */
4
- export declare const WAKE_PRESETS: readonly ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "generic"];
4
+ export declare const WAKE_PRESETS: readonly ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "openbot", "generic"];
5
5
  export interface WakeSettings {
6
6
  url: string;
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,12 +1,13 @@
1
1
  {
2
2
  "name": "@a2a-exposed/control-core",
3
- "version": "0.8.18",
3
+ "version": "0.8.20",
4
4
  "description": "A2A endpoint provisioning, routing, and D1 migrations for a2a-exposed.",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "lint": "node scripts/check-repo.mjs lint",
8
8
  "format:check": "node scripts/check-repo.mjs format",
9
- "test:checks": "node --test scripts/check-repo.test.mjs",
9
+ "test:checks": "node --test scripts/*.test.mjs",
10
+ "version:set": "node scripts/set-version.mjs",
10
11
  "prepare": "node control/scripts/build-core.mjs",
11
12
  "prepack": "node control/scripts/build-core.mjs"
12
13
  },