a2a-exposed 0.8.26 → 0.8.28

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.
@@ -0,0 +1,101 @@
1
+ // Shared local setup operations. No shell, token arguments, or child output.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import crypto from "node:crypto";
6
+ import { spawn } from "node:child_process";
7
+ import { CliError } from "./a2a.mjs";
8
+
9
+ export function writeTokenFile(file, token) {
10
+ const dir = path.dirname(file);
11
+ let tmp, fd;
12
+ try {
13
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
14
+ tmp = path.join(dir, `.a2a-token-${crypto.randomBytes(16).toString("hex")}`);
15
+ fd = fs.openSync(tmp, "wx", 0o600);
16
+ fs.fchmodSync(fd, 0o600);
17
+ fs.writeFileSync(fd, token + "\n");
18
+ fs.fsyncSync(fd);
19
+ fs.closeSync(fd); fd = undefined;
20
+ fs.renameSync(tmp, file);
21
+ } catch {
22
+ throw new CliError("could not save the connector token; fix the destination permissions and retry setup; hosted finish needs fresh approval");
23
+ } finally {
24
+ if (fd !== undefined) fs.closeSync(fd);
25
+ if (tmp && fs.existsSync(tmp)) fs.unlinkSync(tmp);
26
+ }
27
+ }
28
+
29
+ function readPrivate(file) {
30
+ let fd;
31
+ try {
32
+ fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
33
+ const stat = fs.fstatSync(fd);
34
+ if (!stat.isFile() || (stat.mode & 0o777) !== 0o600 || stat.size > 1024 * 1024)
35
+ throw new Error();
36
+ return fs.readFileSync(fd, "utf8");
37
+ } catch {
38
+ throw new CliError("secret source must be a readable regular file with mode 600 (no symlinks, maximum 1 MiB)");
39
+ } finally {
40
+ if (fd !== undefined) fs.closeSync(fd);
41
+ }
42
+ }
43
+
44
+ function usableSecret(value) {
45
+ if (typeof value !== "string" || !value.trim() || value === "INSECURE_NO_AUTH" || Buffer.byteLength(value) > 65536)
46
+ throw new CliError("the selected source has no usable signing secret");
47
+ return value;
48
+ }
49
+
50
+ export function validateSecretSource(o) {
51
+ if (["secret-file", "secret-env", "secret-from"].filter((key) => o[key] !== undefined).length !== 1)
52
+ throw new CliError("choose exactly one of --secret-file, --secret-env, or --secret-from hermes");
53
+ if (o["secret-from"] !== undefined && o["secret-from"] !== "hermes")
54
+ throw new CliError("the only secret adapter is hermes");
55
+ if ((o["hermes-home"] || o.route) && o["secret-from"] !== "hermes")
56
+ throw new CliError("--hermes-home and --route require --secret-from hermes");
57
+ if (o["secret-env"] !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(o["secret-env"]))
58
+ throw new CliError("--secret-env must name an environment variable");
59
+ }
60
+
61
+ export function readSigningSecret(o, link, env = process.env) {
62
+ validateSecretSource(o);
63
+ if (o["secret-file"] !== undefined) return usableSecret(readPrivate(o["secret-file"]).replace(/\r?\n$/, ""));
64
+ if (o["secret-env"] !== undefined) return usableSecret(env[o["secret-env"]]);
65
+ if (link.preset !== "hermes") throw new CliError("the Hermes adapter requires the hermes preset");
66
+ const match = /^\/webhooks\/([A-Za-z0-9_-]+)$/.exec(link.path);
67
+ if (!match || (o.route && o.route !== match[1]))
68
+ throw new CliError("Hermes route does not match the approved link path; select the exact route and Hermes home");
69
+ const home = o["hermes-home"] || env.HERMES_HOME || path.join(os.homedir(), ".hermes");
70
+ // Static YAML routes override dynamic subscriptions. Without a YAML dependency,
71
+ // fail closed on any routes key; never guess which secret the gateway uses.
72
+ try {
73
+ const config = fs.readFileSync(path.join(home, "config.yaml"), "utf8");
74
+ if (/\broutes\b/.test(config)) throw new CliError("Hermes static routes may override this subscription; use --secret-file or --secret-env for the exact route secret");
75
+ } catch (error) {
76
+ if (error instanceof CliError) throw error;
77
+ if (error.code !== "ENOENT") throw new CliError("could not check Hermes static route configuration");
78
+ }
79
+ let subscriptions;
80
+ try { subscriptions = JSON.parse(readPrivate(path.join(home, "webhook_subscriptions.json")).replace(/^\uFEFF/, "")); }
81
+ catch (error) {
82
+ if (error instanceof CliError) throw error;
83
+ throw new CliError("could not parse the Hermes subscriptions file");
84
+ }
85
+ const route = Object.hasOwn(subscriptions || {}, match[1]) ? subscriptions[match[1]] : null;
86
+ if (!route || route.enabled === false) throw new CliError("the selected Hermes subscription is missing or disabled");
87
+ return usableSecret(route.secret);
88
+ }
89
+
90
+ export const connectorArgs = (file) => ["tunnel", "run", "--token-file", file];
91
+ export const connectorCommand = (file) => "cloudflared " + connectorArgs(file).map((s) => "'" + s.replaceAll("'", "'\\''") + "'").join(" ");
92
+
93
+ export async function startConnector(file, spawnImpl = spawn) {
94
+ let child;
95
+ try {
96
+ child = spawnImpl("cloudflared", connectorArgs(file), { detached: true, stdio: "ignore", env: Object.fromEntries(Object.entries(process.env).filter(([key]) => ["PATH", "HOME", "TMPDIR", "TEMP", "TMP", "SystemRoot", "WINDIR"].includes(key))) });
97
+ await new Promise((resolve, reject) => { child.once("error", reject); child.once("spawn", resolve); });
98
+ child.unref();
99
+ return child;
100
+ } catch { throw new CliError("could not start cloudflared; install cloudflared and run finish again"); }
101
+ }
package/lib/main.mjs CHANGED
@@ -12,6 +12,7 @@ import * as account from "./account.mjs";
12
12
  import * as upd from "./update.mjs";
13
13
  import * as fb from "./feedback.mjs";
14
14
  import * as un from "./uninstall.mjs";
15
+ import { finishLink, createLink } from "./link-finish.mjs";
15
16
 
16
17
  const VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
17
18
 
@@ -22,10 +23,12 @@ Usage: npx -y a2a-exposed@latest <command> [options]
22
23
  --version, -v print the version
23
24
  --help, -h print this help
24
25
 
26
+ Setup options: --skip-install skips dependency installation when dependencies already exist.
27
+
25
28
  Setup (needs Node 22.18+, e.g. \`mise exec node@22 -- npx -y a2a-exposed@latest ...\`, and a Cloudflare login:
26
29
  npx cf auth login --no-browser; on a VPS where it fails with HTTP 403 before showing a code, export
27
30
  CLOUDFLARE_API_TOKEN instead: setup skill, Troubleshooting. a2a.exposed hosts the A2A endpoint and is open to everyone
28
- (https://a2a.exposed/pricing). When to use it, and the sentence to say:
31
+ (https://a2a.exposed/pricing). When to use it:
29
32
  https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md#hosted-inbox)
30
33
  init [--hostname <host> | --workers-dev [--workers-dev-subdomain NAME]]
31
34
  [--agent-name N] [--agent-description D] [--agent-skills JSON]
@@ -41,14 +44,18 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
41
44
  --workers-dev move an existing custom-domain deployment to workers.dev (and
42
45
  --hostname H moves it back); the old URL answers 301/410
43
46
  --workers-dev-subdomain create the account's workers.dev subdomain if missing
47
+ Uses system script on macOS/Linux, including under automation.
44
48
  --cf-profile cf auth profile for a separate Cloudflare login
45
49
  (npx cf auth create NAME --no-browser); saved, used by every cf call.
46
50
  When whoami lists no accounts, init reads cf accounts list
51
+ outside the Worker directory. A failed list prints cf's error unless --account-id is set
47
52
  --d1-name D1 database to create or reuse (default: worker name)
48
53
  --pairing-approval who approves device-flow pairing requests (default human:
49
54
  the owner token via \`pair approve\`, or the approval password
50
55
  on the /device page; agent: the agent runs \`pair approve\` after
51
56
  the human says yes; off: no pairing endpoints, \`token issue\` only)
57
+ init reports readiness when password approval is enabled; when missing, use
58
+ \`pair set-password --web\` for the human-owned setup page.
52
59
  --pbkdf2-iterations approval-password hashing cost, 50000-100000 (default 100000,
53
60
  the Workers maximum; lower only if /device hits error 1102)
54
61
  --workers-logs on|off persisted Cloudflare Workers Logs (query strings redacted)
@@ -156,6 +163,16 @@ Status
156
163
  \`claude mcp add --transport http a2a-exposed <base URL>/mcp\`.
157
164
  A failed probe, an unreachable card, or a Worker that reports MCP off leaves that line out.
158
165
  Read-only; run it after an interruption and continue from "next step". Exit 1 = broken.
166
+ Prints eight layers in order, each PASS, FAIL, or SKIP, and still checks the
167
+ later layers after one fails: Worker DNS (system lookup, then DNS-over-HTTPS
168
+ when that lookup fails), Worker (TLS, the agent card, and /health), Owner API,
169
+ Wake DNS, Access (protected or not, and whether a service token is configured;
170
+ the token is never printed), Connector, Local webhook (HTTP status of the wake
171
+ endpoint), and Pairing. A 401 or 403 fails the local webhook layer only when the
172
+ response shows Cloudflare Access. The webhook's own auth refusal reports the HTTP
173
+ status and passes, as does a 401 or 403 with no Access marker. A failure names the
174
+ missing or mismatched object and a fix. --json includes layers (eight objects).
175
+ Exit 1 if any layer fails.
159
176
  For about a minute after tunnel create, while the connector reports 0 connections,
160
177
  status prints "waiting for the connector to connect" and exits 0. After that,
161
178
  0 connections is broken. A tunnel status of inactive is "never connected".
@@ -260,7 +277,7 @@ Owner
260
277
  the MCP consent page when the issuer, client id, client secret, and allowlist are all set.
261
278
 
262
279
  Feedback
263
- feedback "message" [--kind bug|idea|other] [--contact x]
280
+ feedback "message" [--json] [--kind bug|idea|other] [--contact x]
264
281
  [--steps S] [--expected S] [--actual S] [--logs S] [--agent A]
265
282
  [--environment local|cloud VM|CI|cloud agent sandbox|other]
266
283
  [--connection webhook|proxy|polling] [--yes]
@@ -272,9 +289,9 @@ Feedback
272
289
  confidential information: secrets, tokens, owner or pairing
273
290
  tokens, private URLs or hostnames, message contents, or
274
291
  personal data. Prints that warning and asks before sending,
275
- unless --yes is set. Off a terminal, --yes is required.
292
+ unless --yes is set. Off a terminal, posting requires --yes.
276
293
  Posts when this A2A endpoint names a feedback URL.
277
- Otherwise prints a link to the bug
294
+ Otherwise prints Not filed and a link to the bug
278
295
  report form and does not open a browser. The CLI adds its
279
296
  version, the A2A endpoint Worker version when the A2A endpoint reports it,
280
297
  the OS, the Node version, the connection (webhook, proxy, or
@@ -282,7 +299,7 @@ Feedback
282
299
  that detection. Remove tokens from the message and from --logs.
283
300
 
284
301
  Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | openbot | generic
285
- Which clients are tested end to end is in the client table:
302
+ Clients and how to load them:
286
303
  https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md#clients
287
304
  Config: ${C.CONFIG_FILE} (override dir with --config-dir DIR or A2A_CONFIG_DIR; env vars override file values)
288
305
  Update notice: at most a daily background check of the npm registry; off with A2A_NO_UPDATE_CHECK=1 (or DO_NOT_TRACK=1)`;
@@ -328,6 +345,7 @@ const deployOpts = {
328
345
  "workers-dev": B, "workers-dev-subdomain": S, "upstream-token-stdin": B, "no-upstream-token": B, "event-secret-stdin": B,
329
346
  };
330
347
  const SPEC = {
348
+ 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 },
331
349
  init: { ...deployOpts, ...tunnelOpts, tunnel: B }, deploy: deployOpts, wake: deployOpts, tunnel: tunnelOpts,
332
350
  inbox: { context: S, from: S, all: B, json: B },
333
351
  reply: { text: S, stdin: B, state: { type: "string", default: "completed" }, artifact: B, "artifact-name": { type: "string", default: "response" }, force: B },
@@ -341,10 +359,11 @@ const SPEC = {
341
359
  export: { sql: B },
342
360
  upstream: { json: B },
343
361
  connect: { alias: S, name: S, json: B, "no-wait": B, replace: B, force: B, "card-url": S },
362
+ // Internal hosted-service integration commands. Keep out of public help until the production flow is available.
344
363
  signup: { "control-url": S, json: B },
345
364
  login: { "control-url": S, json: B },
346
365
  pair: { json: B, web: B, ttl: S, issuer: S, "client-id": S, subjects: S, methods: S, "secret-stdin": B },
347
- feedback: { kind: S, contact: S, steps: S, expected: S, actual: S, logs: S, agent: S, environment: S, connection: S, yes: B },
366
+ feedback: { kind: S, contact: S, steps: S, expected: S, actual: S, logs: S, agent: S, environment: S, connection: S, yes: B, json: B },
348
367
  uninstall: { yes: B, "dry-run": B, dir: S, "worker-dir": S },
349
368
  };
350
369
  const STATES = ["completed", "input-required", "failed", "rejected", "working"];
@@ -388,7 +407,7 @@ export async function main(argv) {
388
407
  if (e && e.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
389
408
  const flag = (String(e.message).match(/'([^']+)'/) || [])[1] || "that flag";
390
409
  if (flag === "-y") throw new CliError("unknown flag -y. To skip a prompt, pass --yes.");
391
- throw new CliError(`unknown flag ${flag}. Run \`${CLI} ${name} --help\` for this command's flags.`);
410
+ throw new CliError(`unknown flag ${flag}. Run \`${CLI} --help\` and see the ${name} section.`);
392
411
  }
393
412
  if (e && String(e.code || "").startsWith("ERR_PARSE_ARGS")) throw new CliError(String(e.message));
394
413
  throw e;
@@ -449,6 +468,10 @@ export async function main(argv) {
449
468
  if (sub === "forward") return address.forward(o);
450
469
  throw new CliError(`unknown peer action ${sub} (announce|forward)`);
451
470
  }
471
+ case "hosted": {
472
+ 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)");
473
+ return (p[1] === "create" ? createLink : finishLink)({ ...o, "control-url": o["control-url"] || C.get("CONTROL_URL") });
474
+ }
452
475
  case "signup":
453
476
  case "login": {
454
477
  const result = await account.deviceLogin({ controlUrl: o["control-url"] || C.get("CONTROL_URL"), intent: name });
@@ -469,21 +492,22 @@ export async function main(argv) {
469
492
  case "outbound": return cmd.outbound(need(p[0], "outbound <taskId>"));
470
493
  case "feedback": {
471
494
  const message = p.join(" ").trim();
472
- need(message, `feedback "message" [--kind bug|idea|other] [--steps S] [--expected S] [--actual S] [--logs S] [--agent A] [--environment E] [--connection webhook|proxy|polling]`);
495
+ need(message, `feedback "message" [--json] [--kind bug|idea|other] [--steps S] [--expected S] [--actual S] [--logs S] [--agent A] [--environment E] [--connection webhook|proxy|polling]`);
473
496
  const kind = o.kind || "other";
474
497
  if (!fb.KINDS.includes(kind)) throw new CliError("--kind must be bug, idea, or other");
475
498
  if (o.connection && !fb.CONNECTIONS.includes(o.connection)) throw new CliError("--connection must be webhook, proxy, or polling");
476
499
  if (kind === "bug" && !String(o.steps || "").trim()) throw new CliError("a bug report needs reproduction steps (--steps)");
477
500
  const repo = await fb.feedbackPublicRepo({ baseUrl: C.get("A2A_BASE_URL"), ownerToken: C.get("A2A_OWNER_TOKEN") });
478
- await fb.confirmPublicIssue(Boolean(o.yes), undefined, undefined, repo);
479
501
  const result = await fb.runFeedback({
502
+ beforePost: () => fb.confirmPublicIssue(Boolean(o.yes), undefined, undefined, repo),
480
503
  message, kind, contact: o.contact || "", version: VERSION,
481
504
  steps: o.steps || "", expected: o.expected || "", actual: o.actual || "", logs: o.logs || "",
482
505
  agent: o.agent || "", environment: o.environment || "",
483
506
  connection: o.connection || "",
484
507
  baseUrl: C.get("A2A_BASE_URL"), ownerToken: C.get("A2A_OWNER_TOKEN"),
485
508
  });
486
- console.log(result.url);
509
+ if (!result.filed) console.error("Not filed: this A2A endpoint has no feedback inbox. Open this link to file it on GitHub:");
510
+ console.log(o.json ? JSON.stringify(result.filed ? { filed: true, issue: result.url } : result) : result.url);
487
511
  return;
488
512
  }
489
513
  default: throw new CliError(`unknown command ${name} (see --help)`);
package/lib/script.mjs ADDED
@@ -0,0 +1,56 @@
1
+ // Node's "pipe" stdin is a socket on macOS. BSD script refuses its ioctl
2
+ // error, so relay stdin through a real shell pipe before allocating the PTY.
3
+ import { spawn } from "node:child_process";
4
+
5
+ export function spawnScript(args, options = {}) {
6
+ if (process.platform !== "darwin") return forwardShutdown(spawn("script", args, { ...options, detached: true, stdio: ["pipe", "pipe", "pipe"] }));
7
+ // All command arguments remain positional, never interpolated into shell code.
8
+ // fd 3 reports script completion so cat cannot keep the wrapper alive waiting
9
+ // for input. The terminal job cannot write that channel or inherit it.
10
+ const relay = '/bin/cat | { "$@" 3>&-; result=$?; printf "%s\\n" "$result" >&3; exit "$result"; }';
11
+ const child = spawn("/bin/sh", ["-c", relay, "a2a-script", "/usr/bin/script", ...args], {
12
+ ...options, detached: true, stdio: ["pipe", "pipe", "pipe", "pipe"],
13
+ });
14
+ child.stdio[3].once("data", () => child.stdin.end());
15
+ child.stdio[3].resume();
16
+ child.stdin.on("error", () => {}); // script can exit just before an answer.
17
+ return forwardShutdown(child);
18
+ }
19
+
20
+ export function stopScript(child, signal = "SIGTERM") {
21
+ if (!child.pid) return false;
22
+ try {
23
+ // Stop the relay and script together. Closing the PTY master also hangs
24
+ // up the terminal job rather than leaving it waiting for an answer.
25
+ process.kill(-child.pid, signal);
26
+ return true;
27
+ } catch (error) {
28
+ if (error.code === "ESRCH") return false;
29
+ throw error;
30
+ }
31
+ }
32
+
33
+ // Detached PTYs do not receive the CLI's terminal signals. Forward shutdown
34
+ // before allowing the parent's usual signal behavior, and release all hooks
35
+ // on spawn failure or completion so repeated commands do not retain children.
36
+ function forwardShutdown(child) {
37
+ const handlers = new Map();
38
+ const cleanup = () => {
39
+ for (const [signal, handler] of handlers) process.removeListener(signal, handler);
40
+ process.removeListener("exit", onExit);
41
+ };
42
+ const onExit = () => stopScript(child);
43
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
44
+ const handler = () => {
45
+ stopScript(child, signal);
46
+ cleanup();
47
+ if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
48
+ };
49
+ handlers.set(signal, handler);
50
+ process.on(signal, handler);
51
+ }
52
+ process.on("exit", onExit);
53
+ child.once("error", cleanup);
54
+ child.once("close", cleanup);
55
+ return child;
56
+ }