a2a-exposed 0.8.29 → 0.8.31

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/account.mjs CHANGED
@@ -38,6 +38,8 @@ async function readJson(response) {
38
38
  export async function deviceLogin({
39
39
  controlUrl,
40
40
  intent = "login",
41
+ scope = "",
42
+ machine = "",
41
43
  fetchImpl = fetch,
42
44
  sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
43
45
  write = (line) => console.error(line),
@@ -48,7 +50,8 @@ export async function deviceLogin({
48
50
  const codeResponse = await fetchImpl(`${origin}/api/auth/device/code`, {
49
51
  method: "POST",
50
52
  headers: { "content-type": "application/json", accept: "application/json" },
51
- body: JSON.stringify({ client_id: CLI_CLIENT_ID }),
53
+ redirect: "error", signal: AbortSignal.timeout(30_000),
54
+ body: JSON.stringify({ client_id: CLI_CLIENT_ID, ...(scope ? { scope, machine } : {}) }),
52
55
  });
53
56
  const started = await readJson(codeResponse);
54
57
  if (!codeResponse.ok || !started.device_code || !started.user_code || !started.verification_uri_complete) {
@@ -57,7 +60,11 @@ export async function deviceLogin({
57
60
  ? `login is not turned on at ${origin}`
58
61
  : `could not start sign-in at ${origin} (${reason})`);
59
62
  }
63
+ let verification;
64
+ try { verification = new URL(started.verification_uri_complete); } catch { die("invalid sign-in approval URL"); }
65
+ if (verification.origin !== origin || verification.username || verification.password || !/^[A-Za-z0-9-]{4,32}$/.test(started.user_code)) die("invalid sign-in approval URL or comparison code");
60
66
  const verb = intent === "signup" ? "Create an account and approve" : "Sign in and approve";
67
+ if (scope) write(`Hosted setup access\nMachine: ${machine}\nPermissions: ${scope.split(" ").join(", ")}`);
61
68
  write(`${verb} this code:\n${started.user_code}\n${started.verification_uri_complete}`);
62
69
  const interval = Math.max(1, Number(started.interval) || 5);
63
70
  const deadline = Date.now() + Math.max(1, Number(started.expires_in) || 600) * 1000;
@@ -66,11 +73,18 @@ export async function deviceLogin({
66
73
  await sleep(wait * 1000 * scale());
67
74
  const tokenResponse = await fetchImpl(`${origin}/api/auth/device/token`, {
68
75
  method: "POST",
76
+ redirect: "error", signal: AbortSignal.timeout(30_000),
69
77
  headers: { "content-type": "application/json", accept: "application/json" },
70
78
  body: JSON.stringify({ grant_type: DEVICE_GRANT, device_code: started.device_code, client_id: CLI_CLIENT_ID }),
71
79
  });
72
80
  const body = await readJson(tokenResponse);
73
81
  if (tokenResponse.ok && typeof body.access_token === "string" && body.access_token) {
82
+ if (scope) {
83
+ if (!/^a2asg_[a-f0-9]{64}$/.test(body.access_token) || body.scope !== scope) die("invalid hosted setup grant response; start approval again");
84
+ save({ HOSTED_SETUP_URL: origin, HOSTED_SETUP_TOKEN: body.access_token, HOSTED_SETUP_SCOPES: scope, HOSTED_SETUP_MACHINE: machine });
85
+ write("Hosted setup approved. Credential saved privately.");
86
+ return { controlUrl: origin, email: "" };
87
+ }
74
88
  save({ CONTROL_URL: origin, CONTROL_TOKEN: body.access_token });
75
89
  const sessionResponse = await fetchImpl(`${origin}/api/auth/get-session`, {
76
90
  headers: { accept: "application/json", authorization: `Bearer ${body.access_token}` },
package/lib/config.mjs CHANGED
@@ -54,6 +54,12 @@ export function get(key, dflt = "") {
54
54
  return f !== undefined && f !== "" ? f : dflt;
55
55
  }
56
56
 
57
+ /** Config type, without network or Cloudflare calls. Partial self-hosted metadata stays self-hosted. */
58
+ export function deploymentKind() {
59
+ if (get("A2A_WORKER_NAME") || get("A2A_D1_ID")) return "self-hosted";
60
+ return get("A2A_BASE_URL") && get("A2A_OWNER_TOKEN") ? "hosted" : "none";
61
+ }
62
+
57
63
  /** Resolve a control credential with the origin from the same source.
58
64
  * A partial environment override must never borrow saved credentials.
59
65
  */
package/lib/hosted.mjs ADDED
@@ -0,0 +1,202 @@
1
+ // Hosted setup credentials never leave their origin or appear in command output.
2
+ import fs from "node:fs";
3
+ import * as C from "./config.mjs";
4
+ import { controlOrigin, deviceLogin } from "./account.mjs";
5
+ import { CliError, upstreamUrlProblem } from "./a2a.mjs";
6
+
7
+ export const SETUP_SCOPES = ["agents:create", "agents:owner-token", "wake:write", "wake:test"];
8
+ const NEXT = "Run hosted capabilities --json to list scopes and presets; login --machine NAME --scopes SCOPES to approve setup.";
9
+ const fail = (code, next) => { const error = new CliError(next); error.result = { ok: false, code, next }; throw error; };
10
+ const printable = value => typeof value === "string" && value.length <= 4096 && !/[\x00-\x1f\x7f]/.test(value);
11
+
12
+ export function setupSession(env = process.env, saved = C.fileConfig()) {
13
+ const source = env.HOSTED_SETUP_URL || env.HOSTED_SETUP_TOKEN ? env : saved;
14
+ return { origin: controlOrigin(source.HOSTED_SETUP_URL), token: source.HOSTED_SETUP_TOKEN || "" };
15
+ }
16
+
17
+ function originFor(o = {}) {
18
+ const origin = controlOrigin(o["control-url"] || setupSession().origin || C.controlSession().origin || "https://a2a.exposed");
19
+ if (!origin) fail("control_url", "--control-url must be an HTTPS origin without a path or credentials.");
20
+ return origin;
21
+ }
22
+
23
+ async function request(origin, path, { token = "", method = "GET", body, fetchImpl = fetch } = {}) {
24
+ let response, data;
25
+ try {
26
+ response = await fetchImpl(`${origin}${path}`, {
27
+ method, redirect: "error", signal: AbortSignal.timeout(30_000),
28
+ headers: { accept: "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { "content-type": "application/json" }) },
29
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
30
+ });
31
+ data = await response.json();
32
+ } 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
+ if (!data || typeof data !== "object" || Array.isArray(data)) fail("invalid_response", "The hosted service returned an invalid setup response.");
34
+ 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"];
36
+ 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."
39
+ : code === "conflict" ? "Wake settings changed. Run wake preview --json, then test the draft again."
40
+ : 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
+ : code === "invalid_grant" || code === "insufficient_scope" ? NEXT
42
+ : "Hosted setup refused the request. Run hosted capabilities --json and check the inputs; no automatic retry was made.";
43
+ fail(code, next);
44
+ }
45
+ return data;
46
+ }
47
+
48
+ export async function capabilities(o = {}) {
49
+ const data = await request(originFor(o), "/api/v1/setup/capabilities");
50
+ 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 };
52
+ }
53
+
54
+ export async function login(o = {}) {
55
+ const origin = originFor(o), machine = o.machine || "";
56
+ if (!machine.trim() || machine.length > 80 || !printable(machine)) fail("machine", "Pass --machine NAME (1–80 printable characters). " + NEXT);
57
+ const scopes = [...new Set(String(o.scopes || "").split(/[ ,]+/).filter(Boolean))];
58
+ if (!scopes.length || scopes.some(s => !SETUP_SCOPES.includes(s))) fail("scope", "Pass --scopes with choices from hosted capabilities --json.");
59
+ const available = await capabilities({ "control-url": origin });
60
+ if (scopes.some(s => !available.scopes.includes(s))) fail("unsupported_scope", NEXT);
61
+ await deviceLogin({ controlUrl: origin, scope: scopes.join(" "), machine: machine.trim(), save: updates => C.saveConfig(updates) });
62
+ return { ok: true, code: "approved", next: "Run hosted list --json, then hosted create --name NAME --json.", control_url: origin };
63
+ }
64
+
65
+ function accountCredential(o) {
66
+ const session = setupSession(), origin = originFor(o);
67
+ if (!session.token || session.origin !== origin) fail("login_required", NEXT);
68
+ return { origin, token: session.token };
69
+ }
70
+
71
+ export async function list(o = {}) {
72
+ const { origin, token } = accountCredential(o);
73
+ const data = await request(origin, "/api/v1/tenants", { token });
74
+ if (!Array.isArray(data.tenants)) fail("invalid_response", "The service did not return an agent listing.");
75
+ 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
+ }
77
+
78
+ export async function create(o = {}) {
79
+ const name = String(o.name || "").trim().toLowerCase();
80
+ 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
+ // Never replace an existing deployment or an environment-selected credential.
82
+ 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
+ const { origin, token } = accountCredential(o);
84
+ const before = await list(o);
85
+ 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
+ const data = await request(origin, "/api/v1/tenants", { token, method: "POST", body: { name } });
87
+ const base = data.base_url || data.public_url;
88
+ if (data.name !== name || !/^a2aot_[A-Za-z0-9_-]{43}$/.test(data.owner_token || "") || !controlOrigin(base)) fail("invalid_response", "Creation returned an invalid credential response. Check hosted list --json before retrying; the agent may already exist.");
89
+ try { C.saveConfig({ A2A_BASE_URL: base, A2A_OWNER_TOKEN: data.owner_token, HOSTED_AGENT_NAME: name, HOSTED_SETUP_URL: origin }); }
90
+ catch { fail("credential_save", "The agent was created, but its one-time credential could not be saved. Do not create it again. Use the agent's account page to recover access."); }
91
+ return { ok: true, code: "created", next: "Run wake set with an explicit URL and preset, reading credentials from protected files or named environment variables.", name, base_url: base };
92
+ }
93
+
94
+ function secret(o, name, envName) {
95
+ const file = o[`${name}-file`], env = o[`${name}-env`], stdin = name === "key" && o["key-stdin"];
96
+ if ([file, env, stdin].filter(Boolean).length > 1) fail("secret_source", `Choose one --${name}-file or --${name}-env${name === "key" ? " or --key-stdin" : ""}.`);
97
+ let value = "";
98
+ try {
99
+ if (file) {
100
+ const stat = fs.lstatSync(file);
101
+ if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) || stat.size > 4098) fail("secret_file", `--${name}-file needs a regular protected file (chmod 600), at most 4098 bytes.`);
102
+ value = fs.readFileSync(file, "utf8").replace(/\r?\n$/, "");
103
+ } else if (stdin) {
104
+ if (process.stdin.isTTY) fail("secret_stdin", "Pipe the key on stdin; terminal prompts are not supported.");
105
+ value = fs.readFileSync(0, "utf8").replace(/\r?\n$/, "");
106
+ } else value = process.env[env || envName] || "";
107
+ } catch (error) { if (error.result) throw error; fail("secret_file", `Could not read --${name}-file; check its path and permissions.`); }
108
+ if (!printable(value) || ((file || env || stdin) && !value)) fail("secret_input", `The ${name} credential must contain 1–4096 printable characters.`);
109
+ return value;
110
+ }
111
+
112
+ function wakeCredential(o, preferOwner = false) {
113
+ const origin = originFor(o), session = setupSession();
114
+ let name = o.agent || C.get("HOSTED_AGENT_NAME");
115
+ // Deployment halves come from one source, independently of setup selection.
116
+ const saved = C.fileConfig();
117
+ const source = process.env.A2A_BASE_URL || process.env.A2A_OWNER_TOKEN ? process.env : saved;
118
+ const owner = source.A2A_OWNER_TOKEN || "", base = source.A2A_BASE_URL || "";
119
+ if (!name && base) { try { name = new URL(base).hostname.split(".")[0]; } catch {} }
120
+ if (!/^[a-z0-9][a-z0-9-]{2,30}[a-z0-9]$/.test(name || "")) fail("agent", "Pass --agent NAME selected from hosted list --json.");
121
+ // An explicit agent must not borrow a different agent's owner token.
122
+ let ownerOrigin = "";
123
+ if (controlOrigin(base)) {
124
+ const parsed = new URL(base);
125
+ if (parsed.hostname.startsWith(`${name}.`)) ownerOrigin = controlOrigin(`${parsed.protocol}//${parsed.host.slice(name.length + 1)}`);
126
+ }
127
+ const ownerMatches = owner && base && ownerOrigin === origin && controlOrigin(base) && new URL(base).hostname.split(".")[0] === name;
128
+ const token = preferOwner && ownerMatches ? owner : session.origin === origin && session.token ? session.token : ownerMatches ? owner : "";
129
+ if (!token) fail("login_required", NEXT);
130
+ return { origin, token, name };
131
+ }
132
+
133
+ // A webhook path or query may contain its credential. Show only the public destination.
134
+ function previewUrl(value) {
135
+ try { const url = new URL(value); return url.protocol === "https:" && !url.username && !url.password ? `${url.origin}${url.pathname === "/" ? "/" : "/…"}` : ""; }
136
+ catch { return ""; }
137
+ }
138
+
139
+ export async function wake(sub, o = {}) {
140
+ 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.");
142
+ if (sub === "prepare") {
143
+ if (!o["reference-file"]) fail("reference_file", "Pass a new --reference-file FILE. The reference is saved mode 600 and never printed.");
144
+ try { fs.lstatSync(o["reference-file"]); fail("reference_file", "Choose a new --reference-file path; existing files are never replaced."); }
145
+ catch (error) { if (error.result) throw error; if (error.code !== "ENOENT") fail("reference_file", "Cannot inspect --reference-file; check its path and permissions."); }
146
+ }
147
+ const { origin, token, name } = wakeCredential(o, sub !== "prepare" && !o["reference-file"] && !o["reference-env"]), path = `/api/v1/agents/${encodeURIComponent(name)}/wake`;
148
+ const current = await request(origin, path, { token });
149
+ if (!Number.isSafeInteger(current.version)) fail("invalid_response", "The service did not return a valid wake version. Saved settings were not changed.");
150
+ 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.");
155
+ const result = await request(origin, path, { token, method: "PUT", body: { version: current.version, secretReference: reference } });
156
+ 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
+ }
158
+ const url = o.url || process.env.WAKE_WEBHOOK_URL || current.url;
159
+ const preset = o.preset || current.preset || "generic";
160
+ const problem = upstreamUrlProblem(url);
161
+ if (problem) fail("url", "Pass --url with a public HTTPS webhook URL without credentials. Use polling if no public webhook is available.");
162
+ const available = await capabilities({ "control-url": origin });
163
+ if (!available.presets.includes(preset)) fail("preset", "Select --preset from hosted capabilities --json.");
164
+ const draft = { version: current.version, url, preset, keyHeader: o["key-header"] ?? current.keyHeader ?? "", key: secret(o, "key", "WAKE_WEBHOOK_KEY"), hmacSecret: secret(o, "hmac-secret", "WAKE_HMAC_SECRET"), accessClientId: secret(o, "access-client-id", "WAKE_ACCESS_CLIENT_ID"), accessClientSecret: secret(o, "access-client-secret", "WAKE_ACCESS_CLIENT_SECRET") };
165
+ if (Boolean(draft.accessClientId) !== Boolean(draft.accessClientSecret)) fail("access", "Supply both Access credentials through protected files or named environment variables.");
166
+ let body = draft;
167
+ if (sub === "prepare" || (sub === "set" && available.secret_reference && token.startsWith("a2asg_"))) {
168
+ if (!available.secret_reference) fail("unsupported", "This service does not support wake references. Use wake set or browser confirmation.");
169
+ if (sub === "prepare" && !o["reference-file"]) fail("reference_file", "Pass a new --reference-file FILE. The reference is saved mode 600 and never printed.");
170
+ const prepared = await request(origin, `${path}/reference`, { token, method: "POST", body: draft });
171
+ if (!/^a2awr_[a-f0-9]{64}$/.test(prepared.reference || "")) fail("invalid_response", "The service did not return a valid reference. No wake was saved.");
172
+ if (sub === "prepare") {
173
+ try { fs.writeFileSync(o["reference-file"], prepared.reference + "\n", { mode: 0o600, flag: "wx" }); }
174
+ catch { fail("reference_file", "Cannot create --reference-file. Choose a new path in a private directory; existing files are never replaced."); }
175
+ return { ok: true, code: "prepared", next: "Use the protected reference with wake set --reference-file FILE, or MCP set_wake secretReference and the same draft settings, within ten minutes. It is single-use even when a test fails." };
176
+ }
177
+ body = { version: current.version, secretReference: prepared.reference };
178
+ }
179
+ const result = await request(origin, sub === "test" ? `${path}/test` : path, { token, method: sub === "test" ? "POST" : "PUT", body });
180
+ // Do not print proofs, arbitrary service text, webhook URLs or unexpected fields.
181
+ 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";
182
+ const ok = result.ok === true && (sub !== "set" || result.saved === true);
183
+ 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." };
184
+ }
185
+
186
+ export async function output(run, o = {}) {
187
+ try {
188
+ const result = await run();
189
+ if (o.json) console.log(JSON.stringify(result));
190
+ else {
191
+ console.log(`${result.code}: ${result.next}`);
192
+ for (const [key, value] of Object.entries(result)) {
193
+ if (!["ok", "code", "next"].includes(key)) console.log(`${key}: ${JSON.stringify(value, null, 2)}`);
194
+ }
195
+ }
196
+ if (!result.ok) process.exitCode = 1;
197
+ } catch (error) {
198
+ 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}`);
200
+ process.exitCode = 1;
201
+ }
202
+ }
package/lib/layers.mjs CHANGED
@@ -61,15 +61,16 @@ function workerDnsLayer(f) {
61
61
  }
62
62
 
63
63
  function workerLayer(f) {
64
+ const fix = f.deployment === "hosted" ? "check the hosted service and saved A2A_BASE_URL; run status again" : `${CLI} deploy`;
64
65
  if (!f.baseUrl) return layer("Worker", "SKIP", "not checked (no base URL)");
65
66
  const https = /^https:/i.test(f.baseUrl);
66
67
  const card = f.card || { ok: false, error: "not checked" };
67
68
  if (https && card.error && TLS_RE.test(card.error))
68
69
  return layer("Worker", "FAIL", `Likely mismatched: TLS certificate for ${f.host}. ${card.error}`, `check the certificate for ${f.host} in Cloudflare (SSL/TLS)`);
69
70
  if (!card.ok)
70
- return layer("Worker", "FAIL", `Likely missing: deployed Worker. Agent card: ${card.error || "unreachable"}`, `${CLI} deploy`);
71
+ return layer("Worker", "FAIL", `Likely missing: deployed Worker. Agent card: ${card.error || "unreachable"}`, fix);
71
72
  if (card.urlProblem)
72
- return layer("Worker", "FAIL", `Likely mismatched: agent card URL. ${card.urlProblem}`, `${CLI} deploy`);
73
+ return layer("Worker", "FAIL", `Likely mismatched: agent card URL. ${card.urlProblem}`, fix);
73
74
  const tls = https ? "TLS ok" : "TLS skipped (not https)";
74
75
  const named = card.name ? ` ("${card.name}")` : "";
75
76
  const cardBit = `${tls}; agent card HTTP ${card.httpStatus || 200}${named}`;
@@ -80,7 +81,7 @@ function workerLayer(f) {
80
81
  : h.httpStatus < 200 || h.httpStatus >= 300
81
82
  ? `/health HTTP ${h.httpStatus}`
82
83
  : `/health HTTP ${h.httpStatus} did not report ok`;
83
- return layer("Worker", "FAIL", `Likely missing: Worker /health. ${cardBit}; ${problem}`, `${CLI} deploy`);
84
+ return layer("Worker", "FAIL", `Likely missing: Worker /health. ${cardBit}; ${problem}`, fix);
84
85
  }
85
86
  return layer("Worker", "PASS", `${cardBit}; /health HTTP ${h.httpStatus || 200}`);
86
87
  }
@@ -91,6 +92,7 @@ function ownerLayer(f) {
91
92
  if (f.ownerApi && f.ownerApi.ok) return layer("Owner API", "PASS", "owner token accepted");
92
93
  const err = (f.ownerApi && f.ownerApi.error) || "not checked";
93
94
  if (err === "not checked") return layer("Owner API", "SKIP", "not checked");
95
+ if (f.deployment === "hosted") return layer("Owner API", "FAIL", `hosted owner credential check failed: ${err}`, "check hosted agent access with its owner");
94
96
  if (/HTTP 401/.test(err)) return layer("Owner API", "FAIL", "Likely mismatched: owner token", `${CLI} init --rotate-owner-token`);
95
97
  return layer("Owner API", "FAIL", `Likely missing: deployed Worker. ${err}`, `${CLI} deploy`);
96
98
  }
@@ -228,8 +228,14 @@ async function setupLink(o, {
228
228
  }
229
229
  if (!connected) throw new CliError("connector did not report connected within 60 seconds; check cloudflared and run finish again for fresh approval");
230
230
  const tested = await request("wake-test", { signing_secret: secret, preset }, grant);
231
- if (!tested.response.ok) throw failure(tested.response.status, tested.body, context, "grant");
232
- if (tested.body.result !== "ok" || tested.body.saved !== true) {
231
+ // These 409 codes come from saveWakeSettings after a successful wake test.
232
+ const saveRejected = tested.response.status === 409 && ["url", "preset", "header", "key", "unavailable", "unreadable", "conflict", "failed"].includes(tested.body.error);
233
+ if (!tested.response.ok && !saveRejected) throw failure(tested.response.status, tested.body, context, "grant");
234
+ if (saveRejected || (tested.body.result === "ok" && tested.body.saved !== true)) {
235
+ write("wake_result=not-saved");
236
+ throw new CliError("The test wake was delivered, but the settings were not saved. Save them in Wake settings on the website, then test again. The saved connector token remains on disk.");
237
+ }
238
+ if (tested.body.result !== "ok") {
233
239
  const { result, message } = wakeFailure(tested.body.result);
234
240
  write(`wake_result=${result}`);
235
241
  throw new CliError(message);
package/lib/main.mjs CHANGED
@@ -9,6 +9,7 @@ import * as st from "./status.mjs";
9
9
  import * as pair from "./pair.mjs";
10
10
  import * as address from "./address.mjs";
11
11
  import * as account from "./account.mjs";
12
+ import * as hosted from "./hosted.mjs";
12
13
  import * as upd from "./update.mjs";
13
14
  import * as fb from "./feedback.mjs";
14
15
  import * as un from "./uninstall.mjs";
@@ -25,6 +26,28 @@ Usage: npx -y a2a-exposed@latest <command> [options]
25
26
 
26
27
  Setup options: --skip-install skips dependency installation when dependencies already exist.
27
28
 
29
+ Hosted setup:
30
+ hosted capabilities --json list setup scopes, wake presets and service capabilities
31
+ login --machine NAME --scopes SCOPES [--control-url URL] --json
32
+ approve the explicit scopes using the printed website link once;
33
+ save the scoped grant locally (mode 600), never print it
34
+ hosted list --json list hosted agents before choosing --name or --agent
35
+ hosted create --name NAME --json
36
+ create and save the owner credential in this config directory;
37
+ use a separate --config-dir for each agent
38
+ wake preview|test|set --agent NAME [--url URL] [--preset P] --json
39
+ hosted settings: read, test a draft, or test then save it
40
+ secrets: --key-file, --key-env, --key-stdin, --hmac-secret-file, --hmac-secret-env,
41
+ --access-client-id-file, --access-client-id-env and --access-client-secret-file, --access-client-secret-env
42
+ wake prepare --agent NAME --url URL --preset P --reference-file FILE --json
43
+ when the service lists secret_reference, upload the draft and save a
44
+ ten-minute single-use reference in a new protected file; never print it
45
+ wake set --agent NAME --reference-file FILE --json
46
+ test and save exactly that reference's draft; --reference-env VAR also works
47
+ MCP set_wake accepts secretReference on services listing that capability
48
+ Hosted wake removal uses the agent's Wake settings on the website.
49
+ Listings and previews show result fields; --json provides structured output.
50
+
28
51
  Setup (needs Node 22.18+, e.g. \`mise exec node@22 -- npx -y a2a-exposed@latest ...\`, and a Cloudflare login:
29
52
  npx cf auth login --no-browser; on a VPS where it fails with HTTP 403 before showing a code, export
30
53
  CLOUDFLARE_API_TOKEN instead: setup skill, Troubleshooting. a2a.exposed hosts the A2A endpoint and is open to everyone
@@ -155,6 +178,7 @@ Remove
155
178
  --dir and --worker-dir are the Worker project directory (default <config dir>/worker)
156
179
 
157
180
  Status
181
+ Hosted-only configs report deployment: hosted and hosted recovery guidance.
158
182
  status [--json] deployment, Worker version, base URL, agent card check (fetched by the CLI), wake
159
183
  mode (webhook / tunnel / none = polling), tunnel state, proxy mode (--upstream):
160
184
  upstream card, Access credential and upstream bearer configured (separately), the
@@ -308,6 +332,8 @@ Update notice: at most a daily background check of the npm registry; off with A2
308
332
 
309
333
  const HELP_TUNNEL = `tunnel create|status|rm
310
334
 
335
+ Self-hosted only. Hosted configs use hosted webhook settings or polling. Check status --json first.
336
+
311
337
  tunnel create [--tunnel-zone Z | --tunnel-hostname H] [--tunnel-origin URL] [--tunnel-path /p]
312
338
  [--show-token] [--zero-trust-org TEAM] [--dir DIR | --worker-dir DIR]
313
339
  Named Cloudflare Tunnel, proxied DNS, and an Access app that admits one service token.
@@ -327,6 +353,7 @@ const HELP_TUNNEL = `tunnel create|status|rm
327
353
 
328
354
  const HELP_UNINSTALL = `uninstall [--yes] [--dry-run] [--dir DIR | --worker-dir DIR]
329
355
 
356
+ Self-hosted only. Hosted agents are deleted through hosted settings and confirmation.
330
357
  Remove the Worker, the wake tunnel when the config has one, and the D1 database.
331
358
  Prints the plan first: Worker name, D1 name and id, account, and the wake hostname
332
359
  when a tunnel is saved. Removal is permanent and takes the messages and paired peers with it.
@@ -347,8 +374,9 @@ const deployOpts = {
347
374
  "workers-dev": B, "workers-dev-subdomain": S, "upstream-token-stdin": B, "no-upstream-token": B, "event-secret-stdin": B,
348
375
  };
349
376
  const SPEC = {
350
- hosted: { 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 },
351
- init: { ...deployOpts, ...tunnelOpts, tunnel: B }, deploy: deployOpts, wake: deployOpts, tunnel: tunnelOpts,
377
+ 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
+ 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,
352
380
  inbox: { context: S, from: S, all: B, json: B },
353
381
  reply: { text: S, stdin: B, state: { type: "string", default: "completed" }, artifact: B, "artifact-name": { type: "string", default: "response" }, force: B },
354
382
  history: { from: S, n: { type: "string", short: "n", default: "50" }, json: B },
@@ -363,7 +391,7 @@ const SPEC = {
363
391
  connect: { alias: S, name: S, json: B, "no-wait": B, replace: B, force: B, "card-url": S },
364
392
  // Internal hosted-service integration commands. Keep out of public help until the production flow is available.
365
393
  signup: { "control-url": S, json: B },
366
- login: { "control-url": S, json: B },
394
+ login: { "control-url": S, json: B, machine: S, scopes: S },
367
395
  pair: { json: B, web: B, ttl: S, issuer: S, "client-id": S, subjects: S, methods: S, "secret-stdin": B },
368
396
  feedback: { kind: S, contact: S, steps: S, expected: S, actual: S, logs: S, agent: S, environment: S, connection: S, yes: B, json: B },
369
397
  uninstall: { yes: B, "dry-run": B, dir: S, "worker-dir": S },
@@ -440,7 +468,7 @@ export async function main(argv) {
440
468
  if (sub === "verify" || sub === "check") return st.upstreamVerify(o);
441
469
  throw new CliError(`unknown upstream action ${sub} (verify)`);
442
470
  }
443
- case "wake": return dep.wake(p[0], o);
471
+ case "wake": return C.deploymentKind() === "hosted" || o.agent ? hosted.output(() => hosted.wake(p[0], o), o) : dep.wake(p[0], o);
444
472
  case "url": {
445
473
  const u = cmd.baseUrl();
446
474
  if (!u) throw new CliError(`no base URL configured in ${C.CONFIG_FILE}: run \`${CLI} init\` (or pass --config-dir / A2A_CONFIG_DIR for another deployment)`);
@@ -471,11 +499,13 @@ export async function main(argv) {
471
499
  throw new CliError(`unknown peer action ${sub} (announce|forward)`);
472
500
  }
473
501
  case "hosted": {
502
+ if (p.length === 1 && ["capabilities", "list", "create"].includes(p[0])) return hosted.output(() => hosted[p[0]](o), o);
474
503
  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)");
475
504
  return (p[1] === "create" ? createLink : finishLink)({ ...o, "control-url": o["control-url"] || C.get("CONTROL_URL") });
476
505
  }
477
506
  case "signup":
478
507
  case "login": {
508
+ if (o.machine || o.scopes) return hosted.output(() => hosted.login(o), o);
479
509
  const result = await account.deviceLogin({ controlUrl: o["control-url"] || C.get("CONTROL_URL"), intent: name });
480
510
  if (o.json) console.log(JSON.stringify({ control_url: result.controlUrl, email: result.email }));
481
511
  return;
package/lib/status.mjs CHANGED
@@ -275,7 +275,7 @@ export function secretsPropagating(tunnel, now = Date.now()) {
275
275
  export function nextStep(s) {
276
276
  const n = nextStepCore(s);
277
277
  const version = s.cliVersion || D.VERSION;
278
- if (s.workerVersion && s.latestVersion && newer(s.latestVersion, s.workerVersion) && !newer(s.workerVersion, version))
278
+ if (s.deployment !== "hosted" && s.workerVersion && s.latestVersion && newer(s.latestVersion, s.workerVersion) && !newer(s.workerVersion, version))
279
279
  n.also.push(`a2a-exposed ${s.latestVersion} is available: run npx -y a2a-exposed@latest deploy`);
280
280
  const sh = (s.peerTokenShadowed || []).map((k) => `${k} is set in the environment and overrides the different peer token saved in ${s.configFile} (e.g. a stale value from before \`connect\` stored a new one): send/poll use the environment value. Unset ${k} where it is set to use the saved token`);
281
281
  const addr = s.addresses;
@@ -289,6 +289,16 @@ export function nextStep(s) {
289
289
  function nextStepCore(s) {
290
290
  const fail = (text) => ({ ok: false, text, also: [] });
291
291
  const done = (text, also = []) => ({ ok: true, text, also });
292
+ if (s.deployment === "hosted") {
293
+ if (!s.card.ok) return fail(`the hosted agent card is not reachable (${s.card.error}). Run \`${CLI} status\` again; if it still fails, check the hosted service and the saved A2A_BASE_URL.`);
294
+ 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
+ if (!s.health?.ok) return fail("the hosted health check failed. Check the hosted service and run status again.");
296
+ 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.");
301
+ }
292
302
  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.`);
293
303
  if (s.baseUrlEnv)
294
304
  return fail(`A2A_BASE_URL is exported as ${s.baseUrlEnv}, but ${s.configFile} has ${s.baseUrlSaved || "(none)"}: CLI commands (and push URLs sent to peers) use the exported value. Unset A2A_BASE_URL (the A2A endpoint URL is always the deployment's own)`);
@@ -392,7 +402,7 @@ function hostOf(url) {
392
402
  function layerFacts(s, extra) {
393
403
  const t = s.tunnel;
394
404
  return {
395
- deployed: s.deployed, baseUrl: s.baseUrl || "", host: extra.host || "", platform: process.platform,
405
+ deployed: s.deployed, deployment: s.deployment, baseUrl: s.baseUrl || "", host: extra.host || "", platform: process.platform,
396
406
  workerDns: extra.workerDns, card: s.card, health: s.health, hasOwnerToken: s.hasOwnerToken, ownerApi: s.ownerApi,
397
407
  wakeHost: extra.wakeHost || "", wakeDns: extra.wakeDns, bare: extra.bare, authed: extra.authed,
398
408
  serviceToken: extra.serviceToken, localServiceToken: extra.localServiceToken, pairing: s.pairing,
@@ -445,6 +455,7 @@ function finishLayers(s, extra) {
445
455
  export async function collect(net = {}) {
446
456
  const s = {
447
457
  configFile: C.CONFIG_FILE,
458
+ deployment: C.deploymentKind(),
448
459
  deployed: !!(C.get("A2A_D1_ID") && C.get("A2A_WORKER_NAME")),
449
460
  worker: C.get("A2A_WORKER_NAME"), d1: C.get("A2A_D1_NAME"), account: C.get("CLOUDFLARE_ACCOUNT_ID"), cfProfile: C.get("CF_PROFILE"),
450
461
  baseUrl: baseUrl(), urlKind: C.get("A2A_HOSTNAME") ? "custom domain" : "workers.dev", cron: C.get("A2A_ENABLE_CRON") === "1",
@@ -455,6 +466,7 @@ export async function collect(net = {}) {
455
466
  peerTokenShadowed: shadowedPeerTokens(), addresses: null,
456
467
  mcp: "unknown", health: null, workerVersion: null, cliVersion: D.VERSION, latestVersion: readCache(cacheFile())?.latest || null,
457
468
  };
469
+ if (s.deployment === "hosted") { s.deployed = true; s.urlKind = "hosted"; }
458
470
  const envBase = (process.env.A2A_BASE_URL || "").replace(/\/$/, "");
459
471
  if (envBase && envBase !== s.baseUrlSaved) s.baseUrlEnv = envBase;
460
472
  if (!s.deployed || !s.baseUrl) return finishLayers(s, { host: "", workerDns: { kind: "skip" }, wakeHost: "", wakeDns: { kind: "skip" }, bare: null, authed: null });
@@ -470,7 +482,13 @@ export async function collect(net = {}) {
470
482
  s.ownerApi = { ok: true };
471
483
  s.wake = { preset: p.preset, configured: !!p.configured, hasKey: !!p.hasKey, hasHmacSecret: !!p.hasHmacSecret,
472
484
  hasAccessServiceToken: !!p.hasAccessServiceToken, urlFingerprint: p.fingerprints?.url || null };
473
- } catch (e) { s.ownerApi = { ok: false, error: e.message }; }
485
+ } catch (e) {
486
+ s.ownerApi = { ok: false, error: e.message };
487
+ if (s.deployment === "hosted" && /HTTP (404|405)\b/.test(e.message)) {
488
+ try { await owner("GET", "/owner/summary"); s.ownerApi = { ok: true }; }
489
+ catch (summaryError) { s.ownerApi = { ok: false, error: summaryError.message }; }
490
+ }
491
+ }
474
492
  if (s.ownerApi.ok) {
475
493
  try {
476
494
  const pr = await owner("GET", "/owner/pairing");
@@ -496,6 +514,7 @@ export async function collect(net = {}) {
496
514
  } catch { /* probe failed: leave mcp unknown so status omits the add line */ }
497
515
  }
498
516
  }
517
+ if (s.deployment === "hosted") return;
499
518
  const dir = D.workerDir({});
500
519
  const haveCf = fs.existsSync(dir);
501
520
  const tunnelHost = C.get("A2A_TUNNEL_HOSTNAME");
@@ -577,7 +596,7 @@ export async function status(o) {
577
596
  else {
578
597
  const rows = [
579
598
  ["config", s.configFile],
580
- ["deployment", s.deployed ? `Worker ${s.worker}, D1 ${s.d1 || s.worker}${s.account ? `, account ${s.account}` : ""}${s.cfProfile ? `, cf profile ${s.cfProfile}` : ""}${s.cron ? ", cron flush on" : ""}` : "none"],
599
+ ["deployment", s.deployment === "hosted" ? "hosted" : s.deployed ? `Worker ${s.worker}, D1 ${s.d1 || s.worker}${s.account ? `, account ${s.account}` : ""}${s.cfProfile ? `, cf profile ${s.cfProfile}` : ""}${s.cron ? ", cron flush on" : ""}` : "none"],
581
600
  ["base URL", s.baseUrl ? `${s.baseUrl} (${s.baseUrlEnv ? `from the exported A2A_BASE_URL; ${s.configFile} has ${s.baseUrlSaved || "none"}` : s.urlKind})` : "(not saved)"],
582
601
  ];
583
602
  if (s.deployed && s.baseUrl) {
package/lib/tunnel.mjs CHANGED
@@ -254,7 +254,12 @@ export function cfWorkDir(preferred) {
254
254
  return base;
255
255
  }
256
256
 
257
+ function refuseHosted() {
258
+ if (C.deploymentKind() === "hosted") die("this is a hosted config. tunnel provisions a self-hosted Worker only. Configure a public HTTPS webhook through the hosted service, or use polling if Cloudflare Tunnel delivery is unavailable. Check `status --json` first.");
259
+ }
260
+
257
261
  function needDeployment(o) {
262
+ refuseHosted();
258
263
  if (!C.get("A2A_D1_ID") || !C.get("A2A_WORKER_NAME")) die(`nothing is deployed from ${C.CONFIG_DIR}. Another deployment? Set A2A_CONFIG_DIR.`);
259
264
  const dir = D.workerDir(o);
260
265
  if (!fs.existsSync(dir)) die(`worker project not found at ${dir}; run \`${CLI} deploy\` once`);
@@ -263,6 +268,7 @@ function needDeployment(o) {
263
268
 
264
269
  /** `cf` cwd for `tunnel rm` / `uninstall`. A missing Worker project uses a temp directory with a writable cache. */
265
270
  function cfDirForRemove(o, { allowMissingProject = false } = {}) {
271
+ refuseHosted();
266
272
  if (!C.get("A2A_D1_ID") || !C.get("A2A_WORKER_NAME")) die(`nothing is deployed from ${C.CONFIG_DIR}. Another deployment? Set A2A_CONFIG_DIR.`);
267
273
  const dir = D.workerDir(o);
268
274
  if (!fs.existsSync(dir) && !allowMissingProject) die(`worker project not found at ${dir}; run \`${CLI} deploy\` once`);
@@ -475,6 +481,7 @@ export function bareProbeNote(withoutToken) {
475
481
  }
476
482
 
477
483
  export async function remove(o, { quiet = false, allowMissingProject = false, skipSecrets = false, secretNames = undefined, rerun = "" } = {}) {
484
+ refuseHosted();
478
485
  if (!TUNNEL_KEYS.some((k) => C.get(k))) {
479
486
  if (!quiet) console.error("nothing to remove");
480
487
  return;
package/lib/uninstall.mjs CHANGED
@@ -78,6 +78,7 @@ function removalError(err, { removed, left, workerGone, workerStill }) {
78
78
 
79
79
  /** One line per removal. A missing Worker or D1 counts as already gone. The config directory stays. */
80
80
  export async function uninstall(o) {
81
+ if (C.deploymentKind() === "hosted") throw new CliError("this is a hosted config. uninstall removes self-hosted infrastructure only. To delete this hosted agent, use the hosted service agent settings and its deletion confirmation. The local config is unchanged.");
81
82
  if (!deploymentSaved())
82
83
  throw new CliError(`nothing is deployed from ${C.CONFIG_DIR}. Another deployment? Set A2A_CONFIG_DIR.`);
83
84
  console.error(uninstallPlan());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.29",
3
+ "version": "0.8.31",
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.29";
2
+ export const VERSION = "0.8.31";