a2a-exposed 0.8.40 → 0.8.42

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/a2a.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  // Minimal A2A client helpers (0.3 + 1.0) and HTTP utilities.
2
2
  import crypto from "node:crypto";
3
+ import { CLI_COMMAND } from "./config.mjs";
3
4
 
4
5
  export const ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
5
6
 
6
7
  /** The one way docs, hints and CLI output tell people (and agents) to run the CLI: npx, always the latest release, no
7
8
  * global install (agent sandboxes block global installs as untrusted code). A global install is an optional speed-up. */
8
- export const CLI = "npx -y a2a-exposed@latest";
9
+ export const CLI = CLI_COMMAND;
9
10
 
10
11
  export class CliError extends Error {}
11
12
  export const die = (msg) => { throw new CliError(msg); };
package/lib/commands.mjs CHANGED
@@ -23,9 +23,16 @@ export async function ownerTry(method, path, body, timeout) {
23
23
  }
24
24
 
25
25
  function printPush(res) {
26
- for (const r of (res && res.push) || []) console.error(`push -> ${r.url}: ${r.ok ? "ok" : "FAILED"} (${r.info})`);
26
+ for (const r of (res && res.push) || []) {
27
+ // Older Workers return a full URL. Print only its host, including on that compatibility path.
28
+ let host = r.host || "unknown host";
29
+ if (!r.host && r.url) { try { host = new URL(r.url).host; } catch { /* omit invalid URLs */ } }
30
+ console.error(`push -> ${r.role || "task push config"} ${host} [${r.fingerprint || "unknown fingerprint"}]: ${r.ok ? "ok" : "FAILED"} (${/^HTTP \d{3}$/.test(r.info) ? r.info : "push request failed"})`);
31
+ }
27
32
  }
28
33
 
34
+ const printState = (state, timestamp, task = "") => console.log(`${task ? `${task} ` : ""}current state: ${plainState({ status: { state } })} (${timestamp || "timestamp unavailable"})`);
35
+
29
36
  function printDelivery(res) {
30
37
  const d = res && res.delivery;
31
38
  if (!d) return;
@@ -102,6 +109,7 @@ export async function inbox(o) {
102
109
  console.log(r.text);
103
110
  console.log("<<< END PEER MESSAGE");
104
111
  if (reply) console.log(`(a reply to a message you sent; read it with \`${CLI} history ${r.contextId}\`. Continue with send, not reply.)`);
112
+ printState(r.state, r.updated);
105
113
  }
106
114
  if (pairing.length) {
107
115
  console.log(`=== ${pairing.length} pending pairing request${pairing.length === 1 ? "" : "s"} (another agent asks to connect; ${pairing[0].approval} approval)`);
@@ -129,8 +137,10 @@ export async function reply(id, o) {
129
137
  const res = await owner("POST", `/owner/tasks/${q(id)}/reply`, {
130
138
  text, state: o.state, artifact: !!o.artifact, artifactName: o["artifact-name"], force: !!o.force,
131
139
  });
140
+ printPush(res.delivery || res);
132
141
  printDelivery(res);
133
142
  console.log(`${id} -> ${o.state}`);
143
+ console.log(`task state saved: ${res.task?.status?.state || o.state} (peers can read it with GetTask even if a push fails)`);
134
144
  }
135
145
 
136
146
  export async function history(ctx, o) {
@@ -138,10 +148,15 @@ export async function history(ctx, o) {
138
148
  if (o.from) qs.set("from", o.from);
139
149
  const rows = await owner("GET", `/owner/history/${q(ctx)}?${qs}`);
140
150
  if (o.json) return out(rows);
151
+ const states = new Map();
152
+ const peers = new Map(rows.filter((e) => e.task_id && e.peer).map((e) => [e.task_id, e.peer]));
141
153
  for (const e of rows) {
142
154
  const tag = [e.event, e.state || e.role].filter(Boolean).join("/");
143
- console.log(`${e.ts} ${(e.dir || "").padEnd(5)} ${(e.peer || "-").padEnd(10)} ${e.task_id || "-"} ${tag}: ${(e.text || "").slice(0, 400)}`);
155
+ const forced = e.data?.forced ? `forced: ${e.data.previousState} → ${e.data.state}; ` : "";
156
+ console.log(`${e.ts} ${(e.dir || "").padEnd(5)} ${(e.peer || "-").padEnd(10)} ${e.task_id || "-"} ${tag}: ${forced}${(e.text || "").slice(0, 400)}`);
157
+ if (e.currentState?.state || e.state) states.set(`${e.peer || peers.get(e.task_id) || "-"}/${e.task_id || "-"}`, e.currentState || { state: e.state, timestamp: e.ts });
144
158
  }
159
+ for (const [task, status] of states) printState(status.state, status.timestamp, states.size > 1 ? task : "");
145
160
  }
146
161
 
147
162
  export async function contexts() {
package/lib/config.mjs CHANGED
@@ -9,7 +9,12 @@ export function defaultConfigDir(base = process.env.XDG_CONFIG_HOME || path.join
9
9
  return path.join(base, "a2a-exposed");
10
10
  }
11
11
 
12
- export const CONFIG_DIR = process.env.A2A_CONFIG_DIR || defaultConfigDir();
12
+ export const CONFIG_DIR = path.resolve(process.env.A2A_CONFIG_DIR || defaultConfigDir());
13
+ export const NON_DEFAULT_CONFIG = CONFIG_DIR !== path.resolve(defaultConfigDir());
14
+ /** POSIX shell word; never interpolate paths as shell syntax. */
15
+ export const shellQuote = (value) => /^[A-Za-z0-9_./:@+,-]+$/.test(value) ? value : "'" + value.replaceAll("'", "'\\''") + "'";
16
+ export const DEFAULT_CLI_COMMAND = "npx -y a2a-exposed@latest";
17
+ export const CLI_COMMAND = DEFAULT_CLI_COMMAND + (NON_DEFAULT_CONFIG ? ` --config-dir ${shellQuote(CONFIG_DIR)}` : "");
13
18
  export const CONFIG_FILE = path.join(CONFIG_DIR, "config.env");
14
19
  export const PEERS_FILE = path.join(CONFIG_DIR, "peers.json");
15
20
 
package/lib/deploy.mjs CHANGED
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import { spawn, spawnSync } from "node:child_process";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import * as C from "./config.mjs";
8
+ import { pathNode, pinnedCommand, sameExecutable } from "./runtime.mjs";
8
9
  import { CLI, cardUrlProblem, cfErrorCode, die, fetchUpstreamCard, fingerprint, httpJson, randomToken, upstreamAuth, upstreamCardUrlProblem, upstreamUrlProblem } from "./a2a.mjs";
9
10
  import { owner, ownerTry } from "./commands.mjs";
10
11
  import { readHidden } from "./pair.mjs";
@@ -33,7 +34,9 @@ export const CLAUDE_CODE_NOTE = [
33
34
  " Then open the new session from the routine's runs and confirm it ran the inbox command without errors.",
34
35
  ].join("\n");
35
36
 
36
- const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "openbot", "generic"];
37
+ export const MAUSBOT_NOTE = "note (mausbot): the first test may only capture a verification sample. Verify and enable the webhook in MausBot, then run wake test again. HTTP 202 proves acceptance; check that the bot reads the inbox and replies.";
38
+
39
+ const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "openbot", "mausbot", "generic"];
37
40
  // WAKE_ACCESS_*: Cloudflare Access service token for a wake URL behind Access (set by `tunnel create`, or exported
38
41
  // by hand for your own Access-protected endpoint)
39
42
  export const WAKE_SECRETS = ["WAKE_WEBHOOK_URL", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET", "WAKE_ACCESS_CLIENT_ID", "WAKE_ACCESS_CLIENT_SECRET"];
@@ -203,7 +206,7 @@ export function deploySecretsFromEnv() {
203
206
 
204
207
  const csv = (s) => String(s || "").split(",").map((x) => x.trim()).filter(Boolean);
205
208
 
206
- /** Save init/deploy flags. Returns { from, to } when the public URL switches between a custom domain and
209
+ /** Validate init/deploy flags, check Node before writing, then save them. Returns { from, to } when the public URL switches between a custom domain and
207
210
  * workers.dev (or to another custom domain), so the caller can tell the user what changed. */
208
211
  function applyFlags(o) {
209
212
  const upd = {};
@@ -273,6 +276,8 @@ function applyFlags(o) {
273
276
  if (upd.A2A_AGENT_SKILLS) {
274
277
  try { if (!Array.isArray(JSON.parse(upd.A2A_AGENT_SKILLS))) throw 0; } catch { die("--agent-skills must be a JSON array of A2A AgentSkill objects"); }
275
278
  }
279
+ // Report local input errors before Node guidance, but never save or deploy on unsupported Node.
280
+ checkNode();
276
281
  C.saveConfig(upd);
277
282
  return switched;
278
283
  }
@@ -561,13 +566,23 @@ function deployedBase() {
561
566
  return base;
562
567
  }
563
568
 
569
+ /** Preserve an explicit/saved hint; default hints carry this deployment's config directory. */
570
+ function configureWakeCommand(o) {
571
+ const saved = C.get("WAKE_CLI_COMMAND");
572
+ if (o["cli-command"] === undefined && (!saved || saved === C.DEFAULT_CLI_COMMAND) && C.NON_DEFAULT_CONFIG)
573
+ C.saveConfig({ WAKE_CLI_COMMAND: C.CLI_COMMAND });
574
+ const fromPath = pathNode();
575
+ if (!sameExecutable(fromPath, process.execPath))
576
+ console.error(`Node runtime: ${process.execPath}; PATH node: ${fromPath || "not found"}. Pinned wake command: ${pinnedCommand()}. To save it, pass --cli-command with that command.`);
577
+ }
578
+
564
579
  export async function init(o) {
565
- checkNode();
566
580
  const prevUpstream = C.fileConfig().A2A_UPSTREAM_URL || "";
567
581
  const switched = applyFlags(o);
568
582
  const upstreamSecrets = await upstreamTokenCheck(o, { upstreamChanged: (C.get("A2A_UPSTREAM_URL") || "") !== prevUpstream });
569
583
  const dir = workerDir(o);
570
- C.saveConfig({ A2A_WORKER_DIR: dir, A2A_WORKER_NAME: workerName() });
584
+ C.saveConfig({ A2A_WORKER_DIR: dir, A2A_WORKER_NAME: workerName(), A2A_NODE_EXEC_PATH: process.execPath });
585
+ configureWakeCommand(o);
571
586
  if (workersDevMode()) {
572
587
  if (!WD.LABEL_RE.test(workerName())) die("on workers.dev the --worker-name must be a DNS label (lowercase letters, digits, hyphens)");
573
588
  if (!C.get("A2A_HOSTNAME")) console.error(`note: no --hostname, deploying to https://${workerName()}.<account subdomain>.workers.dev (${WD.DOCS})`);
@@ -702,7 +717,6 @@ export async function upstreamTokenCheck(o, { upstreamChanged = false } = {}) {
702
717
  }
703
718
 
704
719
  export async function deploy(o) {
705
- checkNode();
706
720
  if (!C.get("A2A_D1_ID")) die(`no saved deployment; run \`${CLI} init\` first`);
707
721
  // init saves A2A_WORKER_NAME before it creates the D1 database, so a saved D1 without a name is a hand-edited config:
708
722
  // refuse rather than guess, so a redeploy never creates a second Worker next to the one this config deployed.
@@ -710,6 +724,7 @@ export async function deploy(o) {
710
724
  die(`${C.CONFIG_FILE} has a saved deployment (A2A_D1_ID) but no A2A_WORKER_NAME; rerun with --worker-name <the Worker it deployed> (saved for next time)`);
711
725
  const prevUpstream = C.fileConfig().A2A_UPSTREAM_URL || "";
712
726
  const switched = applyFlags(o);
727
+ configureWakeCommand(o);
713
728
  const upstreamSecrets = await upstreamTokenCheck(o, { upstreamChanged: (C.get("A2A_UPSTREAM_URL") || "") !== prevUpstream });
714
729
  const dir = workerDir(o);
715
730
  if (workersDevMode() && !WD.LABEL_RE.test(workerName())) die("on workers.dev the worker name must be a DNS label (lowercase letters, digits, hyphens)");
@@ -744,7 +759,35 @@ function guardTunnelUrl() {
744
759
  if (host !== th) die(`this deployment wakes through the tunnel https://${th} (tunnel create); unset WAKE_WEBHOOK_URL, or run \`${CLI} tunnel rm\` first`);
745
760
  }
746
761
 
762
+ /** Delivery diagnostic only: a test wake has no task or execution receipt. */
763
+ export async function checkWake(o, { request = fetch, callOwner = owner } = {}) {
764
+ if (C.get("WAKE_PRESET") !== "hermes") die("wake check requires the hermes preset; inspect wake preview first, then use wake set --preset hermes");
765
+ const local = o["local-url"] || `${C.get("A2A_TUNNEL_ORIGIN", "http://127.0.0.1:8644")}${C.get("A2A_TUNNEL_PATH", "/webhooks/a2a-wake")}`;
766
+ let url;
767
+ try { url = new URL(local); } catch { die("--local-url must be a loopback HTTP(S) webhook URL"); }
768
+ if (!["http:", "https:"].includes(url.protocol) || !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) || url.username || url.password || url.search || url.hash)
769
+ die("--local-url must be a loopback HTTP(S) webhook URL without credentials, query or fragment");
770
+ let status;
771
+ try {
772
+ const response = await request(url, { method: "POST", headers: { "content-type": "application/json", "user-agent": "a2a-exposed-cli" },
773
+ body: JSON.stringify({ event_type: "a2a_wake", kind: "test" }), redirect: "manual", signal: AbortSignal.timeout(10_000) });
774
+ status = response.status;
775
+ await response.body?.cancel();
776
+ } catch { die("unsigned local check could not reach the webhook; start the Hermes gateway and check --local-url"); }
777
+ const result = { unsigned: { status, refused: status === 401 || status === 403 }, signed: null, receiverExecution: "not-verified" };
778
+ if (result.unsigned.refused) {
779
+ const signed = await callOwner("POST", "/owner/wake/test", {});
780
+ result.signed = { status: Number.isInteger(signed.status) ? signed.status : null, delivered: signed.configured !== false && Number.isInteger(signed.status) && signed.status >= 200 && signed.status < 300 };
781
+ }
782
+ console.log(JSON.stringify(result, null, 2));
783
+ if (!result.unsigned.refused || !result.signed?.delivered) {
784
+ process.exitCode = 1;
785
+ console.error(!result.unsigned.refused ? "unsigned local POST must return 401 or 403; check the route's HMAC secret. Signed delivery was skipped." : "signed Worker delivery failed; run wake preview and tunnel status, then retry after secrets propagate");
786
+ } else console.error("delivery checks passed; receiver execution is not verified. Test wakes create no inbox task; inspect the Hermes job output for execution.");
787
+ }
788
+
747
789
  export async function wake(sub, o) {
790
+ if (sub === "check") return checkWake(o);
748
791
  if (sub === "fingerprint") {
749
792
  const fp = localFingerprints();
750
793
  if (!Object.values(fp).some(Boolean)) die(`no ${WAKE_SECRETS.join(" / ")} in the environment`);
@@ -769,6 +812,7 @@ export async function wake(sub, o) {
769
812
  console.log(JSON.stringify(r, null, 2));
770
813
  if (!(r.status >= 200 && r.status < 300)) process.exitCode = 1;
771
814
  else if (C.get("WAKE_PRESET") === "claude-code") console.error(CLAUDE_CODE_NOTE);
815
+ if (r.preset === "mausbot" || C.get("WAKE_PRESET") === "mausbot") console.error(MAUSBOT_NOTE);
772
816
  return;
773
817
  }
774
818
  if (sub === "set") {
@@ -779,6 +823,7 @@ export async function wake(sub, o) {
779
823
  await deploy(o); // wake set = save settings + upload secrets + redeploy, in one step
780
824
  console.error(`wake settings deployed. Next: ${CLI} wake preview, then ${CLI} wake test`);
781
825
  if (C.get("WAKE_PRESET") === "claude-code") console.error(CLAUDE_CODE_NOTE);
826
+ if (C.get("WAKE_PRESET") === "mausbot") console.error(MAUSBOT_NOTE);
782
827
  return;
783
828
  }
784
829
  if (sub === "unset") {
@@ -796,5 +841,5 @@ export async function wake(sub, o) {
796
841
  if (problems.length) die(`could not remove wake secrets:\n ${problems.join("\n ")}`);
797
842
  return console.error("wake secrets removed (wake is now a no-op; use polling)");
798
843
  }
799
- die("usage: wake set|unset|test|preview|fingerprint");
844
+ die("usage: wake set|unset|test|check|preview|fingerprint");
800
845
  }
package/lib/hosted.mjs CHANGED
@@ -36,17 +36,30 @@ function originFor(o = {}) {
36
36
  async function request(origin, path, { token = "", method = "GET", body, fetchImpl = fetch } = {}) {
37
37
  let response, data;
38
38
  const discovery = method === "GET" && path === "/api/v1/setup/capabilities";
39
+ const wakeRequest = /^\/api\/v1\/agents\/[^/]+\/wake(?:\/test)?$/.test(path);
39
40
  try {
40
41
  response = await fetchImpl(`${origin}${path}`, {
41
42
  method, redirect: "error", signal: AbortSignal.timeout(30_000),
42
43
  headers: { accept: "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { "content-type": "application/json" }) },
43
44
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
44
45
  });
45
- if (!(discovery && response.status === 404)) data = await response.json();
46
+ if (!(discovery && response.status === 404)) {
47
+ try { data = await response.json(); }
48
+ catch (error) {
49
+ if (wakeRequest && [401, 403, 404].includes(response.status)) data = {};
50
+ else throw error;
51
+ }
52
+ }
46
53
  } 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."); }
47
54
  if (discovery && response.status === 404) fail("unsupported", SETUP_UNSUPPORTED);
48
55
  if (!data || typeof data !== "object" || Array.isArray(data)) fail("invalid_response", "The hosted service returned an invalid setup response.");
49
56
  if (!response.ok) {
57
+ if (wakeRequest && [401, 403, 404].includes(response.status)) {
58
+ const code = ["invalid_grant", "insufficient_scope"].includes(data.error) ? data.error : `http_${response.status}`;
59
+ fail(code, response.status === 404
60
+ ? "This service does not expose the hosted wake API for this agent. Check --control-url and the selected agent; use dashboard Wake settings or hosted MCP set_wake. No settings were changed."
61
+ : "Hosted wake access was refused. Check that A2A_BASE_URL and A2A_OWNER_TOKEN belong to the same agent and service, or approve the required setup scopes. Use dashboard Wake settings or hosted MCP set_wake if needed. No settings were changed.");
62
+ }
50
63
  if (response.status === 429 && data.error === "too_many_references") {
51
64
  const seconds = retryAfter(response, data);
52
65
  const next = seconds === undefined
@@ -198,8 +211,6 @@ export async function status(o = {}) {
198
211
 
199
212
  export async function wake(sub, o = {}) {
200
213
  if (!["preview", "test", "set", "prepare", "remove", "unset"].includes(sub)) fail("usage", "Use wake preview|test|set|prepare|remove. List presets with hosted capabilities --json.");
201
- if (sub === "set" && C.deploymentKind() === "hosted" && !o.agent)
202
- fail("hosted_config", "This is a hosted config. Use dashboard Wake settings or hosted MCP set_wake; the human enters wake secrets on the confirmation page. For scoped CLI setup, run hosted capabilities --json, then hosted list --json to select an explicit --agent NAME before wake set --agent NAME --url URL --preset P --json. No wake settings were changed.");
203
214
  const removing = sub === "remove" || sub === "unset";
204
215
  const preparingRemoval = sub === "prepare" && o.clear === true;
205
216
  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.");
@@ -226,7 +237,7 @@ export async function wake(sub, o = {}) {
226
237
  const { origin, token, name } = wakeCredential(o, sub !== "prepare" && !o["reference-file"] && !o["reference-env"]), path = `/api/v1/agents/${encodeURIComponent(name)}/wake`;
227
238
  const current = await request(origin, path, { token });
228
239
  if (!Number.isSafeInteger(current.version)) fail("invalid_response", "The service did not return a valid wake version. Saved settings were not changed.");
229
- 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) };
240
+ 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", "mausbot"].includes(current.preset) ? "Authorization" : current.keyHeader || "Authorization", url: previewUrl(current.url) };
230
241
  if (removing || preparingRemoval) {
231
242
  const version = Number(o.version);
232
243
  if (version !== current.version) fail("conflict", "Wake settings changed. Run wake preview --agent NAME --json, review them, then repeat removal with its explicit --version.");
@@ -249,6 +260,7 @@ export async function wake(sub, o = {}) {
249
260
  }
250
261
  const url = o.url || process.env.WAKE_WEBHOOK_URL || current.url;
251
262
  const preset = o.preset || current.preset || "generic";
263
+ if (!url) fail("wake_unconfigured", "No hosted wake URL is configured. Run hosted capabilities --json to list presets, then wake set --preset P with WAKE_WEBHOOK_URL and protected wake credentials. Alternatively use dashboard Wake settings or hosted MCP set_wake; no settings were changed.");
252
264
  const problem = upstreamUrlProblem(url);
253
265
  if (problem) fail("url", "Pass --url with a public HTTPS webhook URL without credentials. Use polling if no public webhook is available.");
254
266
  const available = await capabilities({ "control-url": origin });
package/lib/main.mjs CHANGED
@@ -25,6 +25,7 @@ Usage: npx -y a2a-exposed@latest <command> [options]
25
25
  --help, -h print this help
26
26
 
27
27
  Setup options: --skip-install skips dependency installation when dependencies already exist.
28
+ Local option and missing-deployment errors appear before Node version guidance; deployment still requires Node 22.18+.
28
29
 
29
30
  Hosted setup:
30
31
  hosted capabilities --json list setup scopes, wake presets and service capabilities
@@ -73,6 +74,9 @@ npx cf auth login --no-browser; on a VPS where it fails with HTTP 403 before sho
73
74
  CLOUDFLARE_API_TOKEN instead: setup skill, Troubleshooting. a2a.exposed hosts the A2A endpoint and is open to everyone
74
75
  (https://a2a.exposed/pricing). When to use it:
75
76
  https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md#hosted-inbox)
77
+ env [--json] print export A2A_CONFIG_DIR=<absolute dir> (no credentials; read-only).
78
+ Pin scheduled jobs: mise exec node@22 -- npx -y a2a-exposed@latest
79
+ --config-dir DIR inbox; use an absolute mise path outside your shell.
76
80
  init [--hostname <host> | --workers-dev [--workers-dev-subdomain NAME]]
77
81
  [--agent-name N] [--agent-description D] [--agent-skills JSON]
78
82
  [--provider-organization O --provider-url U] [--preset P] [--worker-name W] [--d1-name D]
@@ -136,15 +140,18 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
136
140
  --upstream-token-stdin read UPSTREAM_TOKEN from stdin (hidden prompt on a terminal)
137
141
  --no-upstream-token the upstream needs no bearer token: skip that check (saved)
138
142
  --cli-command command shown in wake hints (default "npx -y a2a-exposed@latest";
139
- e.g. "node /path/to/repo/cli/bin/a2a-exposed.mjs")
143
+ e.g. "node /path/to/repo/cli/bin/a2a-exposed.mjs"). Non-default config dirs
144
+ are included in default hints; init saves the Node path and prints
145
+ a pinned command if PATH selects another Node.
140
146
  --worker-dir where the Worker project (template copy) lives (default
141
147
  <config dir>/worker). The config dir is --config-dir / A2A_CONFIG_DIR
142
148
  deploy [same flags] redeploy with saved settings (secrets persist); tag the Worker version as a2a-exposed-<version>
143
149
  wake set [--preset P] [--agent-id A] [--key-header H] [--key-prefix X] [--body-template JSON]
144
150
  [--cli-command CMD] [--debounce S] [--busy S] [--max-per-hour N]
145
151
  self-hosted: save wake settings, upload wake secrets from env, and redeploy
146
- hosted config without --agent: stop with dashboard Wake settings / hosted MCP set_wake guidance
147
- hosted CLI: list hosted capabilities --json and hosted list --json, then select --agent NAME
152
+ hosted config: use the saved address and owner token to test then save; no deploy
153
+ hosted CLI: list hosted capabilities --json for presets; scoped setup selects --agent from hosted list --json
154
+ mausbot: HTTPS /hooks/<endpointId> + WAKE_WEBHOOK_KEY Bearer auth from protected env
148
155
  openbot: human supplies WAKE_HMAC_SECRET via protected env/secret storage, never chat or argv
149
156
  the Worker refuses unsigned openbot wakes (a webhook key is not a signing secret)
150
157
  --busy S seconds a conversation stays busy after a sent wake.
@@ -162,6 +169,9 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
162
169
  30 seconds and run it again
163
170
  wake fingerprint fingerprints (first 12 hex of sha256) of WAKE_* exported in this shell
164
171
  wake test send a test wake now and print the HTTP status
172
+ mausbot: verify and enable the first sample in MausBot, then test again
173
+ wake check [--local-url URL] Hermes: require unsigned local 401/403, then Worker test 2xx (delivery only)
174
+ Defaults to the saved tunnel origin/path; --json prints both checks
165
175
  wake unset remove wake secrets (fall back to polling)
166
176
  handler prompts save autonomous, review-all or escalate mode and owner notices;
167
177
  see docs/receiving-messages.md for setup and the every 6h webhook fallback
@@ -169,7 +179,7 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
169
179
  Tunnel (local-only webhooks: OpenClaw gateway, Hermes, ...; needs Zero Trust/Access and a Cloudflare zone
170
180
  anywhere on the account: the A2A endpoint itself can be on workers.dev or a custom hostname)
171
181
  tunnel create [--tunnel-zone Z | --tunnel-hostname H] [--tunnel-origin URL] [--tunnel-path /p] [--show-token]
172
- [--zero-trust-org TEAM]
182
+ [--zero-trust-org TEAM] [--preset hermes --hermes-route a2a-wake --hmac-secret-stdin]
173
183
  named Cloudflare Tunnel + proxied DNS + Access app that admits ONE service token;
174
184
  the Worker sends that token (CF-Access-Client-Id/Secret) on every wake.
175
185
  Wake hostname: wake-<word>-<word>-<4 hex>.<zone>, on the A2A endpoint hostname's zone, else the
@@ -181,8 +191,11 @@ anywhere on the account: the A2A endpoint itself can be on workers.dev or a cust
181
191
  The connector machine needs cloudflared installed
182
192
  (https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/).
183
193
  Defaults: openclaw-* -> http://127.0.0.1:18789 /hooks/wake|agent, hermes -> :8644
184
- (pass --tunnel-path /webhooks/<name>). Token -> <config dir>/tunnel-token (chmod 600).
194
+ /webhooks/a2a-wake (override with --hermes-route NAME). Token -> <config dir>/tunnel-token (chmod 600).
185
195
  Worker secrets show up in about 15 to 40 seconds. Wait about 30 seconds and check again
196
+ Creation prints resource, connector, and optional persistent-service states.
197
+ status reports connections and ephemeral/unknown persistence; macOS service install needs an admin terminal.
198
+ tunnel zones [--json] list available Cloudflare zones before selecting --tunnel-zone
186
199
  tunnel status tunnel/Access/Worker state + probes (bare request blocked? connector up?)
187
200
  A network error says DNS may still be propagating; retry in 30 seconds
188
201
  tunnel rm delete the DNS record, the tunnel, the Access app, and the service token
@@ -214,7 +227,7 @@ Status
214
227
  mode (webhook / tunnel / none = no webhook), tunnel state, proxy mode (--upstream):
215
228
  upstream card, Access credential and upstream bearer configured (separately), the
216
229
  live upstream check, the last failed peer call, card-leak check; warnings for peer
217
- token variables shadowing saved tokens; and the next step to run.
230
+ token variables shadowing saved tokens; Node version/path and wake runtime mismatch; the next step.
218
231
  Older Workers need deploy (exit 1); npm latest hints use the local update cache.
219
232
  When GET /owner/mcp reports enabled and the agent card is reachable, prints
220
233
  \`claude mcp add --transport http a2a-exposed <base URL>/mcp\`.
@@ -260,8 +273,11 @@ Inbox (owner side)
260
273
  reply <taskId> (--text T | --stdin) [--state completed|input-required|rejected|failed|working]
261
274
  [--artifact] [--artifact-name N] [--force]
262
275
  delivery waits at most 18 s; identical retries read the first result
276
+ --force changes a terminal task again; peers see every change
277
+ push results show role/host/URL fingerprint; task state saved confirms storage
263
278
  history <contextId> [--from PEER] [-n 50] [--json]
264
- conversation log. --from is one peer (the wake hint includes it)
279
+ conversation log with forced transitions and current state per task.
280
+ --from is one peer (the wake hint includes it)
265
281
  contexts recent conversations
266
282
  export [--sql] JSON dump of this A2A endpoint on stdout (peer-token hashes and
267
283
  encrypted peer tokens are in the dump; the owner token stays
@@ -360,24 +376,32 @@ Feedback
360
376
  polling), and hosted or self-hosted. --connection overrides
361
377
  that detection. Remove tokens from the message and from --logs.
362
378
 
363
- Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | openbot | generic
379
+ Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | openbot | mausbot | generic
364
380
  Clients and how to load them:
365
381
  https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md#clients
366
382
  Config: ${C.CONFIG_FILE} (override dir with --config-dir DIR or A2A_CONFIG_DIR; env vars override file values)
367
383
  Update notice: at most a daily background check of the npm registry; off with A2A_NO_UPDATE_CHECK=1 (or DO_NOT_TRACK=1)`;
368
384
 
369
- const HELP_TUNNEL = `tunnel create|status|rm
385
+ const HELP_TUNNEL = `tunnel create|zones|status|rm
370
386
 
371
387
  Self-hosted only. Hosted configs use hosted webhook settings or polling. Check status --json first.
372
388
 
373
389
  tunnel create [--tunnel-zone Z | --tunnel-hostname H] [--tunnel-origin URL] [--tunnel-path /p]
374
390
  [--show-token] [--zero-trust-org TEAM] [--dir DIR | --worker-dir DIR]
391
+ [--preset hermes --hermes-route a2a-wake --hmac-secret-stdin]
392
+ Hermes defaults to :8644/webhooks/a2a-wake. --hermes-route selects another route.
393
+ --preset must match the deployed preset (configure with wake set --preset first).
394
+ --hmac-secret-stdin reads the existing subscription secret from protected stdin,
395
+ never prompts or saves it in config.env. See docs/hermes.md for the receiver recipe.
375
396
  Named Cloudflare Tunnel, proxied DNS, and an Access app that admits one service token.
376
397
  The tunnel, the Access app, and the service token are named "a2a-exposed wake <worker>".
377
398
  The DNS host is wake-<word>-<word>-<4 hex>.<zone> unless --tunnel-hostname is set.
378
399
  A finished tunnel creates nothing when you run it again. A partial tunnel stops and
379
400
  asks for tunnel rm. Prints the Access application name and ids, then how to undo it
380
401
  with tunnel rm. Worker secrets show up in about 15 to 40 seconds. Wait about 30 seconds.
402
+ Creation prints resource, connector, and optional persistent-service states.
403
+ status reports connections and ephemeral/unknown persistence; macOS service install needs an admin terminal.
404
+ tunnel zones [--json] List active zones before choosing --tunnel-zone; no resource changes.
381
405
  tunnel status Tunnel, Access, and Worker state, plus two probes.
382
406
  A network error says DNS may still be propagating; retry in 30 seconds.
383
407
  Warns about Access only when the bare request returns an HTTP status other than 401 or 403.
@@ -412,7 +436,7 @@ const deployOpts = {
412
436
  const SPEC = {
413
437
  hosted: { url: S, version: S, "upstream-token-file": S, "upstream-token-env": S, "access-client-id-file": S, "access-client-id-env": S, "access-client-secret-file": S, "access-client-secret-env": S, 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 },
414
438
  init: { ...deployOpts, ...tunnelOpts, tunnel: B }, deploy: deployOpts,
415
- 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,
439
+ wake: { ...deployOpts, "local-url": S, 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, preset: S, "hermes-route": S, "hmac-secret-stdin": B, json: B },
416
440
  inbox: { context: S, from: S, all: B, json: B },
417
441
  reply: { text: S, stdin: B, state: { type: "string", default: "completed" }, artifact: B, "artifact-name": { type: "string", default: "response" }, force: B },
418
442
  history: { from: S, n: { type: "string", short: "n", default: "50" }, json: B },
@@ -422,6 +446,7 @@ const SPEC = {
422
446
  send: { to: S, text: S, context: S, task: S, push: B, proto: S },
423
447
  poll: { to: S, proto: S },
424
448
  status: { json: B, agent: S, "control-url": S },
449
+ env: { json: B },
425
450
  export: { sql: B },
426
451
  upstream: { json: B },
427
452
  connect: { alias: S, name: S, json: B, "no-wait": B, replace: B, force: B, "card-url": S },
@@ -465,7 +490,7 @@ export async function main(argv) {
465
490
  if (extra.length && !ok)
466
491
  throw new CliError("`pair set-password` takes no password argument: it reads the password from the terminal, without echo (never from argv, stdin or the environment). For a one-time web link instead: `pair set-password --web [--ttl MIN] [--json]`");
467
492
  }
468
- upd.check(VERSION);
493
+ if (name !== "env") upd.check(VERSION);
469
494
  let parsed;
470
495
  try {
471
496
  parsed = parseArgs({ args: rest, options: SPEC[name] || {}, allowPositionals: true, strict: true });
@@ -482,6 +507,9 @@ export async function main(argv) {
482
507
  const { values: o, positionals: p } = parsed;
483
508
  if (o["worker-dir"] !== undefined) o.dir = o["worker-dir"];
484
509
  switch (name) {
510
+ case "env":
511
+ if (p.length) throw new CliError("env takes no arguments; use --config-dir DIR to select a directory");
512
+ return console.log(o.json ? JSON.stringify({ A2A_CONFIG_DIR: C.CONFIG_DIR }) : `export A2A_CONFIG_DIR=${C.shellQuote(C.CONFIG_DIR)}`);
485
513
  case "init":
486
514
  if (o.tunnel) tun.preflight(o); // refuse early (hosted preset, missing --tunnel-path) before anything is deployed
487
515
  await dep.init(o);
@@ -491,7 +519,7 @@ export async function main(argv) {
491
519
  throw e;
492
520
  }
493
521
  case "tunnel": {
494
- const sub = need(p[0], "tunnel create|status|rm");
522
+ const sub = need(p[0], "tunnel create|zones|status|rm");
495
523
  if (p.length > 1) throw new CliError(`tunnel ${sub} takes no arguments (${p.slice(1).join(" ")}). Run \`${CLI} tunnel --help\`.`);
496
524
  return tun.tunnel(sub, o);
497
525
  }
package/lib/pair.mjs CHANGED
@@ -142,7 +142,7 @@ export async function setOidc(o, extra = []) {
142
142
  console.error("The client secret was not written there and was not printed.");
143
143
  if (sawStdin && !(process.env.APPROVAL_OIDC_CLIENT_SECRET || "").trim())
144
144
  console.error(`Export APPROVAL_OIDC_CLIENT_SECRET in the environment you use for deploy. This command did not store it.`);
145
- console.error(`The next \`npx -y a2a-exposed@latest deploy\` uploads APPROVAL_OIDC_CLIENT_SECRET only when that variable is set in the environment. A line from --secret-stdin is discarded.`);
145
+ console.error(`The next \`${CLI} deploy\` uploads APPROVAL_OIDC_CLIENT_SECRET only when that variable is set in the environment. A line from --secret-stdin is discarded.`);
146
146
  console.error("Password approval stays available unless --methods oidc (and only once the issuer, client id, secret and allowlist are all set).");
147
147
  }
148
148
 
@@ -290,7 +290,7 @@ async function discover(target) {
290
290
  ep = { device: m.data.device_authorization_endpoint, token: m.data.token_endpoint };
291
291
  }
292
292
  if (!ep) die(`${base} does not offer device-flow pairing (no OAuth2 deviceCode flow on its agent card, no ${u.origin}/.well-known/oauth-authorization-server).
293
- Ask its owner for a token instead: they run \`${CLI} token issue <label>\` and send it to you privately; then \`${CLI} peers add <alias> ${base} --token-stdin\`.`);
293
+ Ask its owner for a token instead: they run \`${C.DEFAULT_CLI_COMMAND} token issue <label>\` and send it to you privately; then \`${CLI} peers add <alias> ${base} --token-stdin\`.`);
294
294
  for (const x of [ep.device, ep.token]) {
295
295
  const e = new URL(x);
296
296
  if (e.protocol !== "https:" && !["127.0.0.1", "localhost", "[::1]"].includes(e.hostname)) die(`refusing a non-https pairing endpoint: ${x}`);
@@ -378,7 +378,7 @@ A different agent at the same URL? Pass another --alias.`);
378
378
  const mins = Math.round((st.expiresAt - Date.now()) / 60000);
379
379
  const swap = !st.replacing ? "" : st.replacesLabel
380
380
  ? ` On approval the peer replaces our token "${st.replacesLabel}" (the old one stops working).`
381
- : ` This peer does not swap tokens: our old token${oldLabel ? ` (label "${oldLabel}" on its side)` : ""} stays active until its owner revokes it${oldLabel ? ` (\`${CLI} token revoke ${oldLabel}\`)` : ""}; tell them.`;
381
+ : ` This peer does not swap tokens: our old token${oldLabel ? ` (label "${oldLabel}" on its side)` : ""} stays active until its owner revokes it${oldLabel ? ` (\`${C.DEFAULT_CLI_COMMAND} token revoke ${oldLabel}\`)` : ""}; tell them.`;
382
382
  emit({ status: "authorization_pending", alias, user_code: st.userCode, verification_uri: st.verificationUri, verification_uri_complete: st.verificationUriComplete, expires_in: Math.round((st.expiresAt - Date.now()) / 1000), interval: st.interval,
383
383
  ...(st.replacing ? { replaces_label: st.replacesLabel } : {}),
384
384
  instructions: `Show the code and the link to your human. They confirm the code with the owner of ${peerName}, who approves it on that page.${swap}` },
@@ -399,7 +399,7 @@ Show this code and link to your human. They confirm the code with the owner of $
399
399
  dropState(alias);
400
400
  const orphan = st.replacing && !r.data.replaced ? (st.replacesLabel || oldLabel || null) : null;
401
401
  const note = r.data.replaced ? ` It replaced our previous token there (label "${label}").`
402
- : st.replacing ? ` Our previous token${orphan ? ` ("${orphan}")` : ""} is still active on the peer until its owner revokes it${orphan ? `: \`${CLI} token revoke ${orphan}\`` : ""}.` : "";
402
+ : st.replacing ? ` Our previous token${orphan ? ` ("${orphan}")` : ""} is still active on the peer until its owner revokes it${orphan ? `: \`${C.DEFAULT_CLI_COMMAND} token revoke ${orphan}\`` : ""}.` : "";
403
403
  emit({ status: "connected", alias, url: base, token_env: tokenEnv, ...(label ? { peer_label: label } : {}), ...(st.replacing ? { replaced: !!r.data.replaced } : {}), ...(envOverrides ? { env_overrides_token: true } : {}) },
404
404
  `connected: peer "${alias}" -> ${base}${label ? ` (we are "${label}" there)` : ""}; token stored in ${C.CONFIG_FILE} as ${tokenEnv} (not printed).${note}
405
405
  Send a message: ${CLI} send --to ${alias} --text "..."`);
@@ -0,0 +1,54 @@
1
+ // Runtime diagnostics only: inspect paths, never execute a saved wake command.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import * as C from "./config.mjs";
5
+
6
+ const canonical = (file) => { try { return fs.realpathSync(file); } catch { return path.resolve(file); } };
7
+ export const sameExecutable = (a, b) => !!a && !!b && canonical(a) === canonical(b);
8
+
9
+ export function pathNode(env = process.env) {
10
+ for (const dir of (env.PATH || "").split(path.delimiter)) {
11
+ const file = path.resolve(dir || ".", process.platform === "win32" ? "node.exe" : "node");
12
+ try {
13
+ fs.accessSync(file, fs.constants.X_OK);
14
+ if (fs.statSync(file).isFile()) return file;
15
+ } catch { /* try the next PATH entry */ }
16
+ }
17
+ return null;
18
+ }
19
+
20
+ /** Pin env-based Node shebangs (including npx and its children) to this runtime. */
21
+ export function pinnedCommand(execPath = process.execPath) {
22
+ return `env PATH=${C.shellQuote(path.dirname(execPath))}:"$PATH" ${C.CLI_COMMAND}`;
23
+ }
24
+
25
+ // Read the first shell word without evaluating expansions, substitutions or commands.
26
+ function firstWord(text) {
27
+ text = text.trim();
28
+ let word = "", quote = "";
29
+ for (let i = 0; i < text.length; i++) {
30
+ const c = text[i];
31
+ if (quote) {
32
+ if (c === quote) quote = "";
33
+ else if (c === "\\" && quote === '"') word += text[++i] || "";
34
+ else word += c;
35
+ } else if (c === "'" || c === '"') quote = c;
36
+ else if (/\s/.test(c)) break;
37
+ else if (c === "\\") word += text[++i] || "";
38
+ else word += c;
39
+ }
40
+ return quote ? "" : word;
41
+ }
42
+
43
+ export function runtimeInfo({ execPath = process.execPath, node = process.versions.node,
44
+ pathExecPath = pathNode(), initExecPath = C.get("A2A_NODE_EXEC_PATH"), command = C.get("WAKE_CLI_COMMAND", C.CLI_COMMAND) } = {}) {
45
+ const first = firstWord(command);
46
+ let wakeExecPath = null;
47
+ if (command === pinnedCommand(execPath)) wakeExecPath = execPath;
48
+ else if (initExecPath && command === pinnedCommand(initExecPath)) wakeExecPath = initExecPath;
49
+ else if (/^(node|npx|a2a-exposed)(\.exe|\.cmd)?$/.test(first)) wakeExecPath = pathExecPath;
50
+ else if (/^node(\.exe)?$/.test(path.basename(first)) && path.isAbsolute(first)) wakeExecPath = first;
51
+ const warning = wakeExecPath && !sameExecutable(wakeExecPath, execPath)
52
+ ? `WAKE_CLI_COMMAND selects a different Node (${wakeExecPath}); this process uses ${execPath}. Pin the wake runtime with --cli-command (see env --help).` : null;
53
+ return { node, execPath, initExecPath: initExecPath || null, pathExecPath, wakeExecPath, warning };
54
+ }