a2a-exposed 0.8.31 → 0.8.33

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
@@ -4,11 +4,23 @@ import * as C from "./config.mjs";
4
4
  import { controlOrigin, deviceLogin } from "./account.mjs";
5
5
  import { CliError, upstreamUrlProblem } from "./a2a.mjs";
6
6
 
7
- export const SETUP_SCOPES = ["agents:create", "agents:owner-token", "wake:write", "wake:test"];
7
+ export const SETUP_SCOPES = ["agents:read", "agents:create", "agents:owner-token", "wake:write", "wake:test"];
8
8
  const NEXT = "Run hosted capabilities --json to list scopes and presets; login --machine NAME --scopes SCOPES to approve setup.";
9
9
  const fail = (code, next) => { const error = new CliError(next); error.result = { ok: false, code, next }; throw error; };
10
10
  const printable = value => typeof value === "string" && value.length <= 4096 && !/[\x00-\x1f\x7f]/.test(value);
11
11
 
12
+ function retryAfter(response, data) {
13
+ const header = response.headers.get("retry-after");
14
+ let seconds;
15
+ if (header && /^\d+$/.test(header)) seconds = Number(header);
16
+ else if (header && /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(header)) {
17
+ const date = Date.parse(header);
18
+ if (Number.isFinite(date)) seconds = Math.max(0, Math.ceil((date - Date.now()) / 1000));
19
+ }
20
+ if (Number.isSafeInteger(seconds) && seconds >= 0) return seconds;
21
+ return Number.isSafeInteger(data.retry_after) && data.retry_after >= 0 ? data.retry_after : undefined;
22
+ }
23
+
12
24
  export function setupSession(env = process.env, saved = C.fileConfig()) {
13
25
  const source = env.HOSTED_SETUP_URL || env.HOSTED_SETUP_TOKEN ? env : saved;
14
26
  return { origin: controlOrigin(source.HOSTED_SETUP_URL), token: source.HOSTED_SETUP_TOKEN || "" };
@@ -32,10 +44,21 @@ async function request(origin, path, { token = "", method = "GET", body, fetchIm
32
44
  } catch { fail("unavailable", "Hosted setup did not answer with JSON. Check the service and try again. Do not repeat a create request until hosted list confirms its result."); }
33
45
  if (!data || typeof data !== "object" || Array.isArray(data)) fail("invalid_response", "The hosted service returned an invalid setup response.");
34
46
  if (!response.ok) {
35
- const known = ["taken", "limit", "invalid_name", "reserved_name", "invalid_grant", "insufficient_scope", "conflict", "unavailable", "not_found", "test", "signing", "unreadable", "preset", "url", "header", "access", "invalid_reference"];
47
+ if (response.status === 429 && data.error === "too_many_references") {
48
+ const seconds = retryAfter(response, data);
49
+ const next = seconds === undefined
50
+ ? "Wait for an unused reference to expire before running wake prepare again; no automatic retry was made."
51
+ : `Wait ${seconds} seconds before running wake prepare again with a new --reference-file; no automatic retry was made.`;
52
+ // Retain the capacity message, but never reflect arbitrary service text or credentials.
53
+ const message = typeof data.message === "string" && /^This setup grant has \d{1,10} unused references\. Wait \d{1,10} seconds before uploading another draft\.$/.test(data.message) ? data.message : undefined;
54
+ const error = new CliError(next);
55
+ error.result = { ok: false, code: "too_many_references", next, ...(message ? { message } : {}), ...(seconds === undefined ? {} : { retry_after: seconds }) };
56
+ throw error;
57
+ }
58
+ const known = ["taken", "limit", "invalid_name", "reserved_name", "invalid_grant", "insufficient_scope", "conflict", "unavailable", "not_found", "test", "signing", "unreadable", "preset", "url", "header", "access", "invalid_reference", "body"];
36
59
  const code = known.includes(data.error) ? data.error : `http_${response.status}`;
37
- const next = code === "taken" || code === "reserved_name" ? "Choose another --name and run hosted list --json before creating."
38
- : code === "limit" ? "The account is at its agent limit. Review hosted list --json and account plans."
60
+ const next = code === "taken" || code === "reserved_name" ? "Choose another --name using hosted names --name NAME --json before creating."
61
+ : code === "limit" ? "The account is at its agent limit. Read hosted capacity --json and review account plans."
39
62
  : code === "conflict" ? "Wake settings changed. Run wake preview --json, then test the draft again."
40
63
  : code === "invalid_reference" ? "The wake reference was already used, expired or does not match this draft. Run wake prepare again with the draft inputs and a new --reference-file."
41
64
  : code === "invalid_grant" || code === "insufficient_scope" ? NEXT
@@ -48,7 +71,7 @@ async function request(origin, path, { token = "", method = "GET", body, fetchIm
48
71
  export async function capabilities(o = {}) {
49
72
  const data = await request(originFor(o), "/api/v1/setup/capabilities");
50
73
  if (!Array.isArray(data.scopes) || !Array.isArray(data.wake?.presets)) fail("unsupported", "This service does not expose hosted setup capabilities yet.");
51
- return { ok: true, code: "capabilities", next: "Select explicit scopes and a wake preset from this listing.", scopes: data.scopes.filter(s => SETUP_SCOPES.includes(s)), presets: data.wake.presets.filter(s => /^[a-z-]+$/.test(s)), secret_reference: data.wake.secret_reference === true, secure_link_create: data.wake.secure_link_create === true };
74
+ return { ok: true, code: "capabilities", next: "Select explicit scopes and a wake preset from this listing.", scopes: data.scopes.filter(s => SETUP_SCOPES.includes(s)), presets: data.wake.presets.filter(s => /^[a-z-]+$/.test(s)), preflight: data.preflight === true, secret_reference: data.wake.secret_reference === true, wake_removal: data.wake.removal === true, secure_link_create: data.wake.secure_link_create === true };
52
75
  }
53
76
 
54
77
  export async function login(o = {}) {
@@ -75,12 +98,37 @@ export async function list(o = {}) {
75
98
  return { ok: true, code: "agents", next: "This listing contains hosted agents only; Cloudflare-linked Workers remain in dashboard Your agents. Select an agent, or run hosted create --name NAME --json.", agents: data.tenants.map(row => ({ name: String(row.name || ""), status: String(row.status || ""), version: row.version })) };
76
99
  }
77
100
 
101
+ export async function names(o = {}) {
102
+ const name = String(o.name || "").trim().toLowerCase();
103
+ if (!/^[a-z0-9][a-z0-9-]{2,30}[a-z0-9]$/.test(name)) fail("invalid_name", "Pass one explicit --name of 4–32 letters, numbers or hyphens, starting and ending with a letter or number. Use hosted names --name NAME --json before approving setup.");
104
+ const origin = originFor(o);
105
+ if (!(await capabilities(o)).preflight) fail("unsupported", "This service does not expose name preflight. Use hosted capabilities --json or the website.");
106
+ const data = await request(origin, `/api/v1/setup/name?name=${encodeURIComponent(name)}`);
107
+ if (data.name !== name || typeof data.available !== "boolean" || !["available", "invalid_name", "reserved_name", "taken"].includes(data.reason) || data.available !== (data.reason === "available")) fail("invalid_response", "The service did not return a valid name availability result. No creation was attempted.");
108
+ return { ok: true, code: "name_availability", name, available: data.available, reason: data.reason, next: data.available ? "This name is available now, without a reservation. Run hosted capacity --json before hosted create --name NAME --json." : "Choose another --name and repeat hosted names --name NAME --json before requesting creation access." };
109
+ }
110
+
111
+ export async function capacity(o = {}) {
112
+ const { origin, token } = accountCredential(o);
113
+ if (!(await capabilities(o)).preflight) fail("unsupported", "This service does not expose capacity preflight. Use hosted capabilities --json or the website.");
114
+ const data = await request(origin, "/api/v1/setup/capacity", { token });
115
+ const count = value => Number.isSafeInteger(value) && value >= 0;
116
+ if (!(data.limit === null || count(data.limit)) || !count(data.used) || !(data.remaining === null || count(data.remaining)) || data.remaining !== (data.limit === null ? null : Math.max(0, data.limit - data.used)) || data.can_create !== (data.remaining === null || data.remaining > 0)) fail("invalid_response", "The service did not return valid account capacity. No creation was attempted.");
117
+ return { ok: true, code: "capacity", limit: data.limit, used: data.used, remaining: data.remaining, can_create: data.can_create, next: data.can_create ? "This is a read-only snapshot. Check hosted names --name NAME --json, then explicitly approve creation scopes if needed and run hosted create --name NAME --json." : "The account has no creation room. Review its agents and plan before another creation attempt." };
118
+ }
119
+
78
120
  export async function create(o = {}) {
79
121
  const name = String(o.name || "").trim().toLowerCase();
80
122
  if (!/^[a-z0-9][a-z0-9-]{2,30}[a-z0-9]$/.test(name)) fail("invalid_name", "Pass --name with 4–32 letters, numbers or hyphens, starting and ending with a letter or number. Run hosted list --json first.");
81
123
  // Never replace an existing deployment or an environment-selected credential.
82
124
  if (C.get("A2A_BASE_URL") || C.get("A2A_OWNER_TOKEN") || C.get("A2A_WORKER_NAME") || C.get("A2A_D1_ID")) fail("config_in_use", "Use --config-dir with a separate directory for the new agent; keep the existing deployment configuration.");
83
125
  const { origin, token } = accountCredential(o);
126
+ const service = await capabilities(o);
127
+ if (service.preflight) {
128
+ const available = await names(o);
129
+ if (!available.available) fail(available.reason, "Choose a name with hosted names --name NAME --json before creating.");
130
+ if (!(await capacity(o)).can_create) fail("limit", "The account is at its agent limit. Read hosted capacity --json and review its agents or plan.");
131
+ }
84
132
  const before = await list(o);
85
133
  if (before.agents.some(row => row.name === name && row.status !== "deleted")) fail("taken", "That name already belongs to this account. Select it from hosted list --json or choose another --name.");
86
134
  const data = await request(origin, "/api/v1/tenants", { token, method: "POST", body: { name } });
@@ -136,22 +184,60 @@ function previewUrl(value) {
136
184
  catch { return ""; }
137
185
  }
138
186
 
187
+ export async function status(o = {}) {
188
+ if (!/^[a-z0-9][a-z0-9-]{2,30}[a-z0-9]$/.test(o.agent || "")) fail("agent", "Pass --agent NAME selected from hosted list --json.");
189
+ const preview = await wake("preview", o);
190
+ return { ...preview, code: "status", agent: o.agent, next: preview.configured
191
+ ? "Wake settings are configured. Run wake test --agent NAME with explicit draft inputs to check delivery."
192
+ : "No webhook is configured. Select a preset from hosted capabilities --json, then prepare a wake draft, or use polling." };
193
+ }
194
+
139
195
  export async function wake(sub, o = {}) {
140
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.");
141
- if (sub === "remove" || sub === "unset") fail("unsupported", "This hosted API does not support wake removal yet. Remove it in the agent's Wake settings; saved settings were not changed.");
197
+ const removing = sub === "remove" || sub === "unset";
198
+ const preparingRemoval = sub === "prepare" && o.clear === true;
199
+ 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.");
200
+ if (removing || preparingRemoval) {
201
+ if (o.url || o.preset || Object.keys(o).some(key => /^(?:key|hmac-secret|access-client)/.test(key))) fail("removal_inputs", "Omit draft settings and secrets when removing a wake. Run wake preview --json first.");
202
+ if (!/^(0|[1-9][0-9]*)$/.test(String(o.version ?? "")) || !Number.isSafeInteger(Number(o.version))) fail("version", "Pass --version N selected from wake preview --agent NAME --json. Removal never selects a version automatically.");
203
+ }
142
204
  if (sub === "prepare") {
143
205
  if (!o["reference-file"]) fail("reference_file", "Pass a new --reference-file FILE. The reference is saved mode 600 and never printed.");
144
206
  try { fs.lstatSync(o["reference-file"]); fail("reference_file", "Choose a new --reference-file path; existing files are never replaced."); }
145
207
  catch (error) { if (error.result) throw error; if (error.code !== "ENOENT") fail("reference_file", "Cannot inspect --reference-file; check its path and permissions."); }
146
208
  }
209
+ let reference;
210
+ if ((sub === "set" || removing) && (o["reference-file"] || o["reference-env"])) {
211
+ if (o.url || o.preset || o["key-file"] || o["key-env"] || o["key-stdin"] || o["hmac-secret-file"] || o["hmac-secret-env"]) fail("reference_inputs", "Use a reference without draft overrides; it saves exactly the uploaded settings.");
212
+ const next = "Run wake prepare --agent NAME with the draft inputs and a new --reference-file FILE, then use that protected reference with wake set. Existing files are never replaced.";
213
+ try { reference = secret(o, "reference", ""); }
214
+ catch (error) {
215
+ if (error.result?.code === "secret_input") fail("invalid_reference", next);
216
+ throw error;
217
+ }
218
+ if (!/^a2awr_[a-f0-9]{64}$/.test(reference)) fail("invalid_reference", next);
219
+ }
147
220
  const { origin, token, name } = wakeCredential(o, sub !== "prepare" && !o["reference-file"] && !o["reference-env"]), path = `/api/v1/agents/${encodeURIComponent(name)}/wake`;
148
221
  const current = await request(origin, path, { token });
149
222
  if (!Number.isSafeInteger(current.version)) fail("invalid_response", "The service did not return a valid wake version. Saved settings were not changed.");
150
223
  if (sub === "preview") return { ok: true, code: "preview", next: "Test an explicit draft before saving.", version: current.version, preset: current.preset, configured: Boolean(current.url), key_header: ["hermes", "openbot"].includes(current.preset) ? "" : ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent"].includes(current.preset) ? "Authorization" : current.keyHeader || "Authorization", url: previewUrl(current.url) };
151
- if (sub === "set" && (o["reference-file"] || o["reference-env"])) {
152
- if (o.url || o.preset || o["key-file"] || o["key-env"] || o["key-stdin"] || o["hmac-secret-file"] || o["hmac-secret-env"]) fail("reference_inputs", "Use a reference without draft overrides; it saves exactly the uploaded settings.");
153
- const reference = secret(o, "reference", "");
154
- if (!/^a2awr_[a-f0-9]{64}$/.test(reference)) fail("invalid_reference", "Read a valid reference from a protected --reference-file or --reference-env.");
224
+ if (removing || preparingRemoval) {
225
+ const version = Number(o.version);
226
+ if (version !== current.version) fail("conflict", "Wake settings changed. Run wake preview --agent NAME --json, review them, then repeat removal with its explicit --version.");
227
+ const available = await capabilities({ "control-url": origin });
228
+ if (!available.wake_removal || (preparingRemoval && !available.secret_reference)) fail("unsupported", "This service does not support this removal operation. Use the agent's Wake settings on the website.");
229
+ if (preparingRemoval) {
230
+ const prepared = await request(origin, `${path}/reference`, { token, method: "POST", body: { version, clear: true } });
231
+ if (!/^a2awr_[a-f0-9]{64}$/.test(prepared.reference || "")) fail("invalid_response", "The service did not return a valid removal reference. No wake was removed.");
232
+ try { fs.writeFileSync(o["reference-file"], prepared.reference + "\n", { mode: 0o600, flag: "wx" }); }
233
+ catch { fail("reference_file", "Cannot create --reference-file. Choose a new path in a private directory; existing files are never replaced."); }
234
+ return { ok: true, code: "prepared", version, next: "Within ten minutes, use MCP set_wake with clear: true and secretReference, or wake remove --version N --reference-file FILE. The reference is single-use and never printed." };
235
+ }
236
+ const result = await request(origin, path, { token, method: "DELETE", body: { version, ...(reference ? { secretReference: reference } : {}) } });
237
+ const ok = result.ok === true && (result.removed === true || result.already_removed === true);
238
+ return { ok, code: ok ? result.already_removed === true ? "already_removed" : "removed" : "failed", removed: result.removed === true, already_removed: result.already_removed === true, version: result.version, next: ok ? "Use polling to read messages." : "Read wake preview --json before retrying removal with an explicit --version." };
239
+ }
240
+ if (reference) {
155
241
  const result = await request(origin, path, { token, method: "PUT", body: { version: current.version, secretReference: reference } });
156
242
  return { ok: result.ok === true && result.saved === true, code: result.saved === true ? "saved" : "failed", saved: result.saved === true, next: result.saved === true ? "Check the receiver for the test wake." : "The reference is spent. Check saved settings before preparing another draft." };
157
243
  }
@@ -196,7 +282,7 @@ export async function output(run, o = {}) {
196
282
  if (!result.ok) process.exitCode = 1;
197
283
  } catch (error) {
198
284
  const result = error.result || { ok: false, code: "unavailable", next: "Hosted setup did not finish. Check hosted list --json before retrying." };
199
- console.log(o.json ? JSON.stringify(result) : `${result.code}: ${result.next}`);
285
+ console.log(o.json ? JSON.stringify(result) : `${result.code}: ${result.next}${result.message ? `\nmessage: ${result.message}` : ""}`);
200
286
  process.exitCode = 1;
201
287
  }
202
288
  }
package/lib/main.mjs CHANGED
@@ -31,7 +31,10 @@ Hosted setup:
31
31
  login --machine NAME --scopes SCOPES [--control-url URL] --json
32
32
  approve the explicit scopes using the printed website link once;
33
33
  save the scoped grant locally (mode 600), never print it
34
- hosted list --json list hosted agents before choosing --name or --agent
34
+ hosted names --name NAME --json
35
+ check one name anonymously before device approval; never lists other agents
36
+ hosted capacity --json read account room with agents:read (or agents:create); reserves nothing
37
+ hosted list --json list hosted agents with agents:read or agents:create before choosing an agent
35
38
  hosted create --name NAME --json
36
39
  create and save the owner credential in this config directory;
37
40
  use a separate --config-dir for each agent
@@ -45,7 +48,13 @@ Hosted setup:
45
48
  wake set --agent NAME --reference-file FILE --json
46
49
  test and save exactly that reference's draft; --reference-env VAR also works
47
50
  MCP set_wake accepts secretReference on services listing that capability
48
- Hosted wake removal uses the agent's Wake settings on the website.
51
+ Hosted reference capacity failures report too_many_references and a retry wait; never auto-retry.
52
+ wake remove --agent NAME --version N --json
53
+ read wake preview --json first; remove only the version explicitly selected
54
+ wake unset is an alias; credentials remain in protected storage
55
+ wake prepare --agent NAME --clear --version N --reference-file FILE --json
56
+ prepare a removal reference for MCP set_wake clear: true, secretReference;
57
+ without that reference MCP keeps browser confirmation
49
58
  Listings and previews show result fields; --json provides structured output.
50
59
 
51
60
  Setup (needs Node 22.18+, e.g. \`mise exec node@22 -- npx -y a2a-exposed@latest ...\`, and a Cloudflare login:
@@ -178,6 +187,9 @@ Remove
178
187
  --dir and --worker-dir are the Worker project directory (default <config dir>/worker)
179
188
 
180
189
  Status
190
+ status --agent NAME [--control-url URL] [--json]
191
+ read selected hosted agent wake configuration; select NAME with hosted list --json.
192
+ Uses setup approval or that agent's matching owner credential; no delivery test.
181
193
  Hosted-only configs report deployment: hosted and hosted recovery guidance.
182
194
  status [--json] deployment, Worker version, base URL, agent card check (fetched by the CLI), wake
183
195
  mode (webhook / tunnel / none = polling), tunnel state, proxy mode (--upstream):
@@ -376,7 +388,7 @@ const deployOpts = {
376
388
  const SPEC = {
377
389
  hosted: { name: S, json: B, agent: S, hostname: S, origin: S, path: S, preset: S, "control-url": S, "secret-file": S, "secret-env": S, "secret-from": S, "hermes-home": S, route: S, "token-file": S, "no-run": B },
378
390
  init: { ...deployOpts, ...tunnelOpts, tunnel: B }, deploy: deployOpts,
379
- wake: { ...deployOpts, json: B, url: S, "reference-file": S, "reference-env": S, agent: S, "control-url": S, "key-header": S, "key-file": S, "key-env": S, "key-stdin": B, "hmac-secret-file": S, "hmac-secret-env": S, "access-client-id-file": S, "access-client-id-env": S, "access-client-secret-file": S, "access-client-secret-env": S }, tunnel: tunnelOpts,
391
+ wake: { ...deployOpts, json: B, url: S, "reference-file": S, "reference-env": S, clear: B, version: S, agent: S, "control-url": S, "key-header": S, "key-file": S, "key-env": S, "key-stdin": B, "hmac-secret-file": S, "hmac-secret-env": S, "access-client-id-file": S, "access-client-id-env": S, "access-client-secret-file": S, "access-client-secret-env": S }, tunnel: tunnelOpts,
380
392
  inbox: { context: S, from: S, all: B, json: B },
381
393
  reply: { text: S, stdin: B, state: { type: "string", default: "completed" }, artifact: B, "artifact-name": { type: "string", default: "response" }, force: B },
382
394
  history: { from: S, n: { type: "string", short: "n", default: "50" }, json: B },
@@ -385,7 +397,7 @@ const SPEC = {
385
397
  peer: { to: S, all: B, reissue: B, hours: S, off: B, json: B },
386
398
  send: { to: S, text: S, context: S, task: S, push: B, proto: S },
387
399
  poll: { to: S, proto: S },
388
- status: { json: B },
400
+ status: { json: B, agent: S, "control-url": S },
389
401
  export: { sql: B },
390
402
  upstream: { json: B },
391
403
  connect: { alias: S, name: S, json: B, "no-wait": B, replace: B, force: B, "card-url": S },
@@ -462,7 +474,7 @@ export async function main(argv) {
462
474
  if (p.length) throw new CliError(`uninstall takes no arguments (${p.join(" ")}). Run \`${CLI} uninstall --help\`.`);
463
475
  return un.uninstall(o);
464
476
  case "deploy": return dep.deploy(o);
465
- case "status": return st.status(o);
477
+ case "status": return o.agent !== undefined || o["control-url"] !== undefined ? hosted.output(() => hosted.status(o), o) : st.status(o);
466
478
  case "upstream": {
467
479
  const sub = need(p[0], "upstream verify [--json]");
468
480
  if (sub === "verify" || sub === "check") return st.upstreamVerify(o);
@@ -499,7 +511,7 @@ export async function main(argv) {
499
511
  throw new CliError(`unknown peer action ${sub} (announce|forward)`);
500
512
  }
501
513
  case "hosted": {
502
- if (p.length === 1 && ["capabilities", "list", "create"].includes(p[0])) return hosted.output(() => hosted[p[0]](o), o);
514
+ if (p.length === 1 && ["capabilities", "list", "create", "names", "capacity"].includes(p[0])) return hosted.output(() => hosted[p[0]](o), o);
503
515
  if (p.length !== 2 || p[0] !== "link" || !["finish", "create"].includes(p[1])) throw new CliError("usage: hosted link create --agent NAME --hostname HOST --origin URL --path PATH --preset ID (--secret-file FILE | --secret-env VAR | --secret-from hermes); or hosted link finish --agent NAME (--secret-file FILE | --secret-env VAR | --secret-from hermes)");
504
516
  return (p[1] === "create" ? createLink : finishLink)({ ...o, "control-url": o["control-url"] || C.get("CONTROL_URL") });
505
517
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.31",
3
+ "version": "0.8.33",
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": {
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.31";
2
+ export const VERSION = "0.8.33";