a2a-exposed 0.8.28 → 0.8.29

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.
@@ -8,6 +8,7 @@ import { readSigningSecret, validateSecretSource, writeTokenFile, startConnector
8
8
 
9
9
  const sleepDefault = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
10
  const ERRORS = {
11
+ expired_token: "The link request expired. Start a new one with hosted link create.",
11
12
  unauthorized: "Run a2a-exposed login, then run finish again.",
12
13
  invalid_request: "Check the agent name, hostname and link options before running the command again.",
13
14
  link_exists: "The link request was refused; review the approval page before changing or removing an existing link.",
@@ -18,10 +19,45 @@ const ERRORS = {
18
19
  provision_failed: "Check the approval page for the failing provisioning phase and cleanup state before running the command again.",
19
20
  preset: "Select a valid wake preset with --preset <id>, then run finish again.",
20
21
  signing_secret: "Check the local signing-secret source and receiver configuration before running the command again.",
21
- unreadable: "Reconnect Cloudflare on the website, then run finish again.",
22
+ unreadable: "The secure link access key could not be read. Remove the secure link on the website, then run create again.",
23
+ slow_down: "Too many pending requests for this agent. Deny an earlier request on the website or wait for it to expire, then run finish again.",
24
+ access_denied: "The request was denied. Run finish again only if you still want to set up this link.",
25
+ link_not_ready: "The secure link is not ready. Finish creating it in Wake settings on the website, then run finish again.",
26
+ invalid_grant: "This grant is not valid. Run finish again and approve a new request.",
27
+ already_delivered: "Credentials were already delivered for this request. Run finish again for fresh approval if you did not receive them.",
28
+ connector_not_connected: "The connector is not connected. Start cloudflared with the saved token file, then run finish again for fresh approval.",
29
+ not_found: "That agent or request was not found. Check --agent on the website, then run finish again.",
30
+ cloudflare_unavailable: "Cloudflare could not be reached. Reconnect Cloudflare in Wake settings on the website, then run finish again.",
22
31
  unavailable: "The service is temporarily unavailable; retry later with fresh approval.",
23
32
  };
24
- const APPROVAL_ERRORS = new Set(["access_denied", "expired_token", "link_not_ready", "invalid_grant", "already_delivered", "connector_not_connected", "not_found", "cloudflare_unavailable"]);
33
+
34
+ // Fixed local guidance only: wake results must never expose server text.
35
+ const WAKE_NEXT_STEPS = {
36
+ none: "Update the hosted Worker, then run finish again for fresh approval.",
37
+ "dns-missing": "Wait for DNS propagation, then run finish again for fresh approval.",
38
+ "access-401": "Check the signing secret and Access service-token pair, then run finish again for fresh approval.",
39
+ "access-403": "Check the signing secret and Access service-token pair, then run finish again for fresh approval.",
40
+ timeout: "Retry later by running finish again for fresh approval.",
41
+ "origin-429": "Retry later by running finish again for fresh approval.",
42
+ "origin-5xx": "Retry later by running finish again for fresh approval.",
43
+ "origin-NNN": "Check the receiver's HTTP response, then run finish again for fresh approval.",
44
+ tls: "Fix the receiver's TLS certificate, then run finish again for fresh approval.",
45
+ private: "Use a public HTTPS wake URL, then run finish again for fresh approval.",
46
+ dns: "Check the wake hostname's DNS records, then run finish again for fresh approval.",
47
+ connection: "Check that the receiver is reachable, then run finish again for fresh approval.",
48
+ unreadable: "Remove the secure link on the website, then run create again.",
49
+ failed: "Check the receiver configuration, then run finish again for fresh approval.",
50
+ unknown: "Update the CLI (npx a2a-exposed@latest) and send feedback.",
51
+ };
52
+
53
+ function wakeFailure(raw) {
54
+ const value = typeof raw === "string" ? raw : "";
55
+ const normalized = /^[1-5]\d{2}$/.test(value) ? `origin-${value}` : value;
56
+ const result = Object.hasOwn(WAKE_NEXT_STEPS, normalized) && !["origin-5xx", "origin-NNN"].includes(normalized)
57
+ ? normalized : /^origin-\d{3}$/.test(normalized) ? normalized : "unknown";
58
+ const guidance = WAKE_NEXT_STEPS[result] ?? WAKE_NEXT_STEPS[/^origin-5\d{2}$/.test(result) ? "origin-5xx" : "origin-NNN"];
59
+ return { result, message: `wake test of the new secure link failed (${result}); saved wake settings were not changed. ${guidance}` };
60
+ }
25
61
 
26
62
  // Only these public validation templates may cross the server-text boundary.
27
63
  // Never echo arbitrary bodies, fetch errors, child output, or credential-bearing fields.
@@ -34,23 +70,47 @@ const VALIDATION_MESSAGES = new Set([
34
70
  "Use an http or https origin with no path or credentials.",
35
71
  "Use a path beginning with /, with no query or fragment.",
36
72
  ]);
73
+ const DOMAIN = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
37
74
  function validationDetail(body, hostname) {
38
75
  if (typeof body?.message !== "string") return "";
39
76
  if (VALIDATION_MESSAGES.has(body.message)) return body.message.replace(/ and try again\.$/, ".");
77
+ if (body.reason === "outside_zone" || body.error === "outside_zone") {
78
+ const prefix = "That hostname is not on a domain in your Cloudflare account. ";
79
+ if (body.message === prefix + "Add a domain to Cloudflare, then run create again.") return body.message;
80
+ const match = body.message.match(/^That hostname is not on a domain in your Cloudflare account\. Use a hostname under (.+)\.$/);
81
+ if (match && match[1].split(" or ").every((domain) => DOMAIN.test(domain))) return body.message;
82
+ }
40
83
  const apex = `Use a hostname under ${hostname}, such as wake.${hostname}. The domain itself is not a secure link.`;
41
84
  return hostname && body.message === apex ? apex : "";
42
85
  }
43
86
 
44
- function failure(status, body, { command, hostname }) {
45
- const code = Object.hasOwn(ERRORS, body?.error) || APPROVAL_ERRORS.has(body?.error) ? body.error : `HTTP ${status}`;
46
- const detail = ["invalid_request", "apex_hostname"].includes(code) ? validationDetail(body, hostname) : "";
47
- const message = detail || (Object.hasOwn(ERRORS, code) ? ERRORS[code].replace(/run finish/g, `run ${command}`) : "check the link on the website and rerun the command for fresh approval");
87
+ function provisionGuidance(body) {
88
+ if (body.phase === "cleanup") return "Remove the unfinished secure link on the website, then run create again.";
89
+ if (["hostname", "zone", "existing_dns", "existing_tunnel"].includes(body.phase))
90
+ return "Choose another hostname on a domain in your Cloudflare account, then run create again.";
91
+ if (body.status === 401 || body.status === 403)
92
+ return "Cloudflare refused access. Reconnect Cloudflare on the website, then run create again.";
93
+ if (body.status === 429 || (Number.isInteger(body.status) && body.status >= 500 && body.status <= 599))
94
+ return "Cloudflare was busy. Run create again in a few minutes.";
95
+ return ERRORS.provision_failed;
96
+ }
97
+
98
+ function failure(status, body, { command, hostname }, phase = "requests") {
99
+ const known = (value) => typeof value === "string" && Object.hasOwn(ERRORS, value);
100
+ const code = known(body?.reason) ? body.reason : known(body?.error) ? body.error : `HTTP ${status}`;
101
+ const detail = phase === "requests" && ["invalid_request", "apex_hostname", "outside_zone"].includes(code) ? validationDetail(body, hostname) : "";
102
+ let guidance = code === "provision_failed" ? provisionGuidance(body) : Object.hasOwn(ERRORS, code) ? ERRORS[code] : "check the link on the website and rerun the command for fresh approval";
103
+ if (code === "expired_token") guidance = ["grant", "connector"].includes(phase)
104
+ ? `The approval grant expired. Run hosted link finish again for fresh approval; ${phase === "grant" ? "the saved connector token remains on disk and " : ""}wake settings were not saved.`
105
+ : `The link request expired. Start a new one with hosted link ${command}.`;
106
+ if (code === "invalid_request" && phase === "token") guidance = `The approval polling request was refused. Run hosted link ${command} again for fresh approval.`;
107
+ const message = detail || guidance.replace(/run finish/gi, (match) => `${match.startsWith("R") ? "Run" : "run"} ${command}`);
48
108
  return new CliError(`link ${command} failed${hostname ? ` for ${hostname}` : ""} (${code}); ${message}`);
49
109
  }
50
110
 
51
- export function savedLinkSession(origin, session = C.controlSession()) {
111
+ export function savedLinkSession(origin, session = C.controlSession(), command = "finish") {
52
112
  if (controlOrigin(session.origin) !== origin || !session.token)
53
- throw new CliError("sign in to this service first: run login --control-url <service-origin>, then rerun link setup; environment sessions require both CONTROL_URL and CONTROL_TOKEN");
113
+ throw new CliError(`sign in to this service first: run login --control-url <service-origin>, then rerun hosted link ${command}; environment sessions require both CONTROL_URL and CONTROL_TOKEN`);
54
114
  return session.token;
55
115
  }
56
116
 
@@ -100,7 +160,7 @@ async function setupLink(o, {
100
160
  if (o.preset !== undefined && (typeof o.preset !== "string" || !/^[a-z][a-z0-9-]{0,63}$/.test(o.preset))) throw new CliError("--preset must be a wake preset id");
101
161
  const origin = controlOrigin(o["control-url"]);
102
162
  if (!origin) throw new CliError("--control-url must be an https origin with no path");
103
- const session = sessionToken(origin);
163
+ const session = sessionToken(origin, undefined, context.command);
104
164
  const request = async (endpoint, body, grant, timeout = 15000) => {
105
165
  let response;
106
166
  try {
@@ -134,10 +194,10 @@ async function setupLink(o, {
134
194
  const token = await request("token", { request_id: setup.request_id, verifier });
135
195
  if (token.response.status === 428 && token.body.error === "authorization_pending") continue;
136
196
  if (token.response.status === 429 && token.body.error === "slow_down") { interval += 5; continue; }
137
- if (!token.response.ok) throw failure(token.response.status, token.body, context);
197
+ if (!token.response.ok) throw failure(token.response.status, token.body, context, "token");
138
198
  authorized = token.body; break;
139
199
  }
140
- if (!authorized) throw failure(410, { error: "expired_token" }, context);
200
+ if (!authorized) throw failure(410, { error: "expired_token" }, context, "token");
141
201
  const { grant, link } = authorized;
142
202
  if (typeof grant !== "string" || !/^[\x21-\x7e]+$/.test(grant) || grant.length > 4096 || link?.agent !== agent || typeof link.path !== "string" || !link.path.startsWith("/") || typeof link.preset !== "string" || typeof link.hostname !== "string" || typeof link.origin !== "string")
143
203
  throw new CliError("invalid approved link response");
@@ -148,7 +208,7 @@ async function setupLink(o, {
148
208
  // Resolve and validate the source before spending the one-time connector delivery.
149
209
  const secret = readSecret(o, { ...link, preset });
150
210
  const connector = await request("connector", {}, grant);
151
- if (!connector.response.ok) throw failure(connector.response.status, connector.body, context);
211
+ if (!connector.response.ok) throw failure(connector.response.status, connector.body, context, "connector");
152
212
  if (typeof connector.body.connector_token !== "string" || !connector.body.connector_token.trim() || /\s/.test(connector.body.connector_token)) throw new CliError("invalid connector response; run finish again for fresh approval");
153
213
  const tokenFile = path.resolve(o["token-file"] || path.join(os.homedir(), ".a2a-exposed", "links", `${agent}.token`));
154
214
  saveToken(tokenFile, connector.body.connector_token);
@@ -162,17 +222,19 @@ async function setupLink(o, {
162
222
  for (let poll = 0; poll < 20 && now() < connectDeadline; poll++) {
163
223
  const status = await request("status", undefined, grant, Math.min(15000, connectDeadline - now()));
164
224
  if (now() >= connectDeadline) break;
165
- if (!status.response.ok) throw failure(status.response.status, status.body, context);
225
+ if (!status.response.ok) throw failure(status.response.status, status.body, context, "grant");
166
226
  if (status.body.connector === "connected" && Number.isInteger(status.body.connections) && status.body.connections > 0) { connected = true; break; }
167
227
  await sleep(Math.min(3000, Math.max(0, connectDeadline - now())));
168
228
  }
169
229
  if (!connected) throw new CliError("connector did not report connected within 60 seconds; check cloudflared and run finish again for fresh approval");
170
230
  const tested = await request("wake-test", { signing_secret: secret, preset }, grant);
171
- if (!tested.response.ok) throw failure(tested.response.status, tested.body, context);
231
+ if (!tested.response.ok) throw failure(tested.response.status, tested.body, context, "grant");
172
232
  if (tested.body.result !== "ok" || tested.body.saved !== true) {
173
- const result = (["dns-missing", "access-401", "access-403", "timeout"].includes(tested.body.result) || /^origin-\d{3}$/.test(tested.body.result)) ? tested.body.result : "unknown";
174
- throw new CliError(`wake test did not save settings (${result}); fix the receiver and run finish again for fresh approval`);
233
+ const { result, message } = wakeFailure(tested.body.result);
234
+ write(`wake_result=${result}`);
235
+ throw new CliError(message);
175
236
  }
237
+ write("wake_result=ok");
176
238
  write("Connector connected. Wake test passed and settings saved.");
177
239
  return { saved: true, connectorStarted: true };
178
240
  }
package/lib/main.mjs CHANGED
@@ -115,6 +115,8 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
115
115
  wake fingerprint fingerprints (first 12 hex of sha256) of WAKE_* exported in this shell
116
116
  wake test send a test wake now and print the HTTP status
117
117
  wake unset remove wake secrets (fall back to polling)
118
+ handler prompts save autonomous, review-all or escalate mode and owner notices;
119
+ see docs/receiving-messages.md for setup and the every 6h webhook fallback
118
120
 
119
121
  Tunnel (local-only webhooks: OpenClaw gateway, Hermes, ...; needs Zero Trust/Access and a Cloudflare zone
120
122
  anywhere on the account: the A2A endpoint itself can be on workers.dev or a custom hostname)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.28",
3
+ "version": "0.8.29",
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.28";
2
+ export const VERSION = "0.8.29";