a2a-exposed 0.8.32 → 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 } });
@@ -146,14 +194,20 @@ export async function status(o = {}) {
146
194
 
147
195
  export async function wake(sub, o = {}) {
148
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.");
149
- 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
+ }
150
204
  if (sub === "prepare") {
151
205
  if (!o["reference-file"]) fail("reference_file", "Pass a new --reference-file FILE. The reference is saved mode 600 and never printed.");
152
206
  try { fs.lstatSync(o["reference-file"]); fail("reference_file", "Choose a new --reference-file path; existing files are never replaced."); }
153
207
  catch (error) { if (error.result) throw error; if (error.code !== "ENOENT") fail("reference_file", "Cannot inspect --reference-file; check its path and permissions."); }
154
208
  }
155
209
  let reference;
156
- if (sub === "set" && (o["reference-file"] || o["reference-env"])) {
210
+ if ((sub === "set" || removing) && (o["reference-file"] || o["reference-env"])) {
157
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.");
158
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.";
159
213
  try { reference = secret(o, "reference", ""); }
@@ -167,6 +221,22 @@ export async function wake(sub, o = {}) {
167
221
  const current = await request(origin, path, { token });
168
222
  if (!Number.isSafeInteger(current.version)) fail("invalid_response", "The service did not return a valid wake version. Saved settings were not changed.");
169
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) };
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
+ }
170
240
  if (reference) {
171
241
  const result = await request(origin, path, { token, method: "PUT", body: { version: current.version, secretReference: reference } });
172
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." };
@@ -212,7 +282,7 @@ export async function output(run, o = {}) {
212
282
  if (!result.ok) process.exitCode = 1;
213
283
  } catch (error) {
214
284
  const result = error.result || { ok: false, code: "unavailable", next: "Hosted setup did not finish. Check hosted list --json before retrying." };
215
- 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}` : ""}`);
216
286
  process.exitCode = 1;
217
287
  }
218
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:
@@ -379,7 +388,7 @@ const deployOpts = {
379
388
  const SPEC = {
380
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 },
381
390
  init: { ...deployOpts, ...tunnelOpts, tunnel: B }, deploy: deployOpts,
382
- 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,
383
392
  inbox: { context: S, from: S, all: B, json: B },
384
393
  reply: { text: S, stdin: B, state: { type: "string", default: "completed" }, artifact: B, "artifact-name": { type: "string", default: "response" }, force: B },
385
394
  history: { from: S, n: { type: "string", short: "n", default: "50" }, json: B },
@@ -502,7 +511,7 @@ export async function main(argv) {
502
511
  throw new CliError(`unknown peer action ${sub} (announce|forward)`);
503
512
  }
504
513
  case "hosted": {
505
- 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);
506
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)");
507
516
  return (p[1] === "create" ? createLink : finishLink)({ ...o, "control-url": o["control-url"] || C.get("CONTROL_URL") });
508
517
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.32",
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.32";
2
+ export const VERSION = "0.8.33";