a2a-exposed 0.8.41 → 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";
@@ -565,12 +566,23 @@ function deployedBase() {
565
566
  return base;
566
567
  }
567
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
+
568
579
  export async function init(o) {
569
580
  const prevUpstream = C.fileConfig().A2A_UPSTREAM_URL || "";
570
581
  const switched = applyFlags(o);
571
582
  const upstreamSecrets = await upstreamTokenCheck(o, { upstreamChanged: (C.get("A2A_UPSTREAM_URL") || "") !== prevUpstream });
572
583
  const dir = workerDir(o);
573
- 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);
574
586
  if (workersDevMode()) {
575
587
  if (!WD.LABEL_RE.test(workerName())) die("on workers.dev the --worker-name must be a DNS label (lowercase letters, digits, hyphens)");
576
588
  if (!C.get("A2A_HOSTNAME")) console.error(`note: no --hostname, deploying to https://${workerName()}.<account subdomain>.workers.dev (${WD.DOCS})`);
@@ -712,6 +724,7 @@ export async function deploy(o) {
712
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)`);
713
725
  const prevUpstream = C.fileConfig().A2A_UPSTREAM_URL || "";
714
726
  const switched = applyFlags(o);
727
+ configureWakeCommand(o);
715
728
  const upstreamSecrets = await upstreamTokenCheck(o, { upstreamChanged: (C.get("A2A_UPSTREAM_URL") || "") !== prevUpstream });
716
729
  const dir = workerDir(o);
717
730
  if (workersDevMode() && !WD.LABEL_RE.test(workerName())) die("on workers.dev the worker name must be a DNS label (lowercase letters, digits, hyphens)");
package/lib/main.mjs CHANGED
@@ -74,6 +74,9 @@ npx cf auth login --no-browser; on a VPS where it fails with HTTP 403 before sho
74
74
  CLOUDFLARE_API_TOKEN instead: setup skill, Troubleshooting. a2a.exposed hosts the A2A endpoint and is open to everyone
75
75
  (https://a2a.exposed/pricing). When to use it:
76
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.
77
80
  init [--hostname <host> | --workers-dev [--workers-dev-subdomain NAME]]
78
81
  [--agent-name N] [--agent-description D] [--agent-skills JSON]
79
82
  [--provider-organization O --provider-url U] [--preset P] [--worker-name W] [--d1-name D]
@@ -137,7 +140,9 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
137
140
  --upstream-token-stdin read UPSTREAM_TOKEN from stdin (hidden prompt on a terminal)
138
141
  --no-upstream-token the upstream needs no bearer token: skip that check (saved)
139
142
  --cli-command command shown in wake hints (default "npx -y a2a-exposed@latest";
140
- 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.
141
146
  --worker-dir where the Worker project (template copy) lives (default
142
147
  <config dir>/worker). The config dir is --config-dir / A2A_CONFIG_DIR
143
148
  deploy [same flags] redeploy with saved settings (secrets persist); tag the Worker version as a2a-exposed-<version>
@@ -188,6 +193,8 @@ anywhere on the account: the A2A endpoint itself can be on workers.dev or a cust
188
193
  Defaults: openclaw-* -> http://127.0.0.1:18789 /hooks/wake|agent, hermes -> :8644
189
194
  /webhooks/a2a-wake (override with --hermes-route NAME). Token -> <config dir>/tunnel-token (chmod 600).
190
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.
191
198
  tunnel zones [--json] list available Cloudflare zones before selecting --tunnel-zone
192
199
  tunnel status tunnel/Access/Worker state + probes (bare request blocked? connector up?)
193
200
  A network error says DNS may still be propagating; retry in 30 seconds
@@ -220,7 +227,7 @@ Status
220
227
  mode (webhook / tunnel / none = no webhook), tunnel state, proxy mode (--upstream):
221
228
  upstream card, Access credential and upstream bearer configured (separately), the
222
229
  live upstream check, the last failed peer call, card-leak check; warnings for peer
223
- 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.
224
231
  Older Workers need deploy (exit 1); npm latest hints use the local update cache.
225
232
  When GET /owner/mcp reports enabled and the agent card is reachable, prints
226
233
  \`claude mcp add --transport http a2a-exposed <base URL>/mcp\`.
@@ -266,8 +273,11 @@ Inbox (owner side)
266
273
  reply <taskId> (--text T | --stdin) [--state completed|input-required|rejected|failed|working]
267
274
  [--artifact] [--artifact-name N] [--force]
268
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
269
278
  history <contextId> [--from PEER] [-n 50] [--json]
270
- 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)
271
281
  contexts recent conversations
272
282
  export [--sql] JSON dump of this A2A endpoint on stdout (peer-token hashes and
273
283
  encrypted peer tokens are in the dump; the owner token stays
@@ -389,6 +399,8 @@ Self-hosted only. Hosted configs use hosted webhook settings or polling. Check s
389
399
  A finished tunnel creates nothing when you run it again. A partial tunnel stops and
390
400
  asks for tunnel rm. Prints the Access application name and ids, then how to undo it
391
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.
392
404
  tunnel zones [--json] List active zones before choosing --tunnel-zone; no resource changes.
393
405
  tunnel status Tunnel, Access, and Worker state, plus two probes.
394
406
  A network error says DNS may still be propagating; retry in 30 seconds.
@@ -434,6 +446,7 @@ const SPEC = {
434
446
  send: { to: S, text: S, context: S, task: S, push: B, proto: S },
435
447
  poll: { to: S, proto: S },
436
448
  status: { json: B, agent: S, "control-url": S },
449
+ env: { json: B },
437
450
  export: { sql: B },
438
451
  upstream: { json: B },
439
452
  connect: { alias: S, name: S, json: B, "no-wait": B, replace: B, force: B, "card-url": S },
@@ -477,7 +490,7 @@ export async function main(argv) {
477
490
  if (extra.length && !ok)
478
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]`");
479
492
  }
480
- upd.check(VERSION);
493
+ if (name !== "env") upd.check(VERSION);
481
494
  let parsed;
482
495
  try {
483
496
  parsed = parseArgs({ args: rest, options: SPEC[name] || {}, allowPositionals: true, strict: true });
@@ -494,6 +507,9 @@ export async function main(argv) {
494
507
  const { values: o, positionals: p } = parsed;
495
508
  if (o["worker-dir"] !== undefined) o.dir = o["worker-dir"];
496
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)}`);
497
513
  case "init":
498
514
  if (o.tunnel) tun.preflight(o); // refuse early (hosted preset, missing --tunnel-path) before anything is deployed
499
515
  await dep.init(o);
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
+ }
package/lib/status.mjs CHANGED
@@ -2,11 +2,14 @@
2
2
  // calls only), so an agent that was interrupted can resume from the printed next step. The CLI fetches the agent card
3
3
  // itself, so agents need no curl (some agent sandboxes flag *.dev URLs in shell commands).
4
4
  import dns from "node:dns";
5
+ import { spawnSync } from "node:child_process";
5
6
  import fs from "node:fs";
6
7
  import { isIP } from "node:net";
7
8
  import path from "node:path";
9
+ import os from "node:os";
8
10
  import { promisify } from "node:util";
9
11
  import * as C from "./config.mjs";
12
+ import { runtimeInfo } from "./runtime.mjs";
10
13
  import { CLI, cardUrlProblem, cfErrorCode, describeHttp, die, fingerprint, httpJson, isPrivateHost } from "./a2a.mjs";
11
14
  import { baseUrl, owner, ownerTry } from "./commands.mjs";
12
15
  import * as D from "./deploy.mjs";
@@ -14,6 +17,44 @@ import { buildLayers, layerValue, usablePin } from "./layers.mjs";
14
17
  import * as T from "./tunnel.mjs";
15
18
  import { cacheFile, readCache, newer } from "./update.mjs";
16
19
 
20
+ /** Best-effort local check only. A terminal-attached process using this exact token file is ephemeral.
21
+ * Never return process arguments (they may contain credentials), or infer persistence from remote connections. */
22
+ export function connectorPersistence(tokenFile, { platform = process.platform, run = spawnSync, exists = fs.existsSync, home = os.homedir() } = {}) {
23
+ if (!["darwin", "linux"].includes(platform) || !tokenFile) return "unknown";
24
+ try {
25
+ const ps = run("ps", ["-axo", "tty=,comm=,args="], { encoding: "utf8", timeout: 2000, maxBuffer: 1024 * 1024 });
26
+ if (ps.status !== 0 || ps.error) return "unknown";
27
+ const fileForms = [tokenFile, C.shellQuote(tokenFile), '"' + tokenFile.replaceAll('"', '\\"') + '"'];
28
+ const foreground = ps.stdout.split("\n").some((line) => {
29
+ const row = line.trim().match(/^(\S+)\s+(\S+)\s+(.+)$/);
30
+ if (!row || row[1] === "-" || row[1].startsWith("?")) return false;
31
+ if (path.basename(row[2]) !== "cloudflared" || !/\btunnel\s+run\b/.test(row[3])) return false;
32
+ return ["--token-file ", "--token-file="].some((flag) => fileForms.some((file) =>
33
+ row[3].endsWith(flag + file) || row[3].includes(flag + file + " --")));
34
+ });
35
+ if (!foreground) return "unknown";
36
+ const serviceFiles = platform === "darwin"
37
+ ? ["/Library/LaunchDaemons/com.cloudflare.cloudflared.plist", "/Library/LaunchAgents/com.cloudflare.cloudflared.plist", path.join(home, "Library/LaunchAgents/com.cloudflare.cloudflared.plist")]
38
+ : ["/etc/systemd/system/cloudflared.service", "/usr/lib/systemd/system/cloudflared.service", path.join(home, ".config/systemd/user/cloudflared.service")];
39
+ if (serviceFiles.some(exists)) return "unknown";
40
+ const service = platform === "darwin"
41
+ ? run("launchctl", ["list"], { encoding: "utf8", timeout: 2000, maxBuffer: 1024 * 1024 })
42
+ : run("systemctl", ["list-unit-files", "--type=service", "--no-pager", "--no-legend"], { encoding: "utf8", timeout: 2000, maxBuffer: 1024 * 1024 });
43
+ if (service.status !== 0 || service.error || /cloudflared/i.test(service.stdout)) return "unknown";
44
+ if (platform === "linux") {
45
+ const user = run("systemctl", ["--user", "list-unit-files", "--type=service", "--no-pager", "--no-legend"], { encoding: "utf8", timeout: 2000, maxBuffer: 1024 * 1024 });
46
+ if (user.status !== 0 || user.error || /cloudflared/i.test(user.stdout)) return "unknown";
47
+ }
48
+ // This describes the observed terminal process, not every possible custom service.
49
+ return "ephemeral";
50
+ } catch { return "unknown"; }
51
+ }
52
+
53
+ export function connectorLine(t) {
54
+ const state = t.connections == null ? "unknown" : t.connections > 0 ? `connected (${t.connections})` : "not connected (0)";
55
+ return `${state}, ${t.persistence === "ephemeral" ? "ephemeral (local terminal connector; no cloudflared service found)" : "persistence unknown"}`;
56
+ }
57
+
17
58
  const DOH_URL = "https://cloudflare-dns.com/dns-query";
18
59
  const LOOKUP_MS = 5000;
19
60
 
@@ -284,7 +325,7 @@ export function nextStep(s) {
284
325
  const n = nextStepCore(s);
285
326
  const version = s.cliVersion || D.VERSION;
286
327
  if (s.deployment !== "hosted" && s.workerVersion && s.latestVersion && newer(s.latestVersion, s.workerVersion) && !newer(s.workerVersion, version))
287
- n.also.push(`a2a-exposed ${s.latestVersion} is available: run npx -y a2a-exposed@latest deploy`);
328
+ n.also.push(`a2a-exposed ${s.latestVersion} is available: run ${CLI} deploy`);
288
329
  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`);
289
330
  const addr = s.addresses;
290
331
  if (addr && Array.isArray(addr.announcePending) && addr.announcePending.length)
@@ -362,7 +403,7 @@ function nextStepCore(s) {
362
403
  if (s.pairing && s.pairing.mode === "human" && !s.pairing.passwordSet)
363
404
  also.push(`peers can't connect yet: run \`${CLI} pair set-password --web\` and send your human the one-time link (or they run \`${CLI} pair set-password\` in a terminal); never set it yourself`);
364
405
  if (s.pairing && s.pairing.pending) also.push(`${s.pairing.pending} pending pairing request(s): \`${CLI} pair list\`, then tell your human (never approve on your own)`);
365
- return done(`none: the façade is up and forwards paired peers' A2A calls to the upstream. Peers connect with \`${CLI} connect ${s.baseUrl}\``, also);
406
+ return done(`none: the façade is up and forwards paired peers' A2A calls to the upstream. Peers connect with \`${C.DEFAULT_CLI_COMMAND} connect ${s.baseUrl}\``, also);
366
407
  }
367
408
  const t = s.tunnel;
368
409
  if (t && !t.complete) return fail(`a previous \`tunnel create\` did not finish: run \`${CLI} tunnel rm\`, then \`${CLI} tunnel create\``);
@@ -402,7 +443,7 @@ function nextStepCore(s) {
402
443
  return done2(`no wake webhook, so ${poll}.`, [`optional: ${tunnel}`, ...(pub ? [`optional: ${pub}`] : [])]);
403
444
  }
404
445
  return done2(`none: setup is complete. Check a real wake with \`${CLI} wake test\`.`,
405
- [`peers connect with \`${CLI} connect ${s.baseUrl}\` (your human approves each one); \`${CLI} token issue <peer>\` is the manual fallback`]);
446
+ [`peers connect with \`${C.DEFAULT_CLI_COMMAND} connect ${s.baseUrl}\` (your human approves each one); \`${CLI} token issue <peer>\` is the manual fallback`]);
406
447
  }
407
448
 
408
449
  function hostOf(url) {
@@ -465,7 +506,7 @@ function finishLayers(s, extra) {
465
506
  * `net.lookup` and `net.doh` replace the system resolver and DNS-over-HTTPS in tests. */
466
507
  export async function collect(net = {}) {
467
508
  const s = {
468
- configFile: C.CONFIG_FILE,
509
+ configFile: C.CONFIG_FILE, runtime: runtimeInfo(),
469
510
  deployment: C.deploymentKind(),
470
511
  deployed: !!(C.get("A2A_D1_ID") && C.get("A2A_WORKER_NAME")),
471
512
  worker: C.get("A2A_WORKER_NAME"), d1: C.get("A2A_D1_NAME"), account: C.get("CLOUDFLARE_ACCOUNT_ID"), cfProfile: C.get("CF_PROFILE"),
@@ -535,7 +576,8 @@ export async function collect(net = {}) {
535
576
  const url = T.wakeUrl(tunnelHost || "?", C.get("A2A_TUNNEL_PATH") || "/");
536
577
  s.tunnel = { hostname: tunnelHost, wakeUrl: url, origin: C.get("A2A_TUNNEL_ORIGIN"), complete: T.tunnelComplete(), tokenFileOk: T.tokenFileOk(),
537
578
  tokenFile: C.get("A2A_TUNNEL_TOKEN_FILE") || "<config dir>/tunnel-token", workerUrlMatches: s.wake?.urlFingerprint === fingerprint(url),
538
- secretsAt: Number(C.get("A2A_TUNNEL_SECRETS_AT")) || 0, connections: null };
579
+ secretsAt: Number(C.get("A2A_TUNNEL_SECRETS_AT")) || 0, connections: null,
580
+ persistence: connectorPersistence(C.get("A2A_TUNNEL_TOKEN_FILE")) };
539
581
  if (haveCf && C.get("A2A_TUNNEL_ID")) {
540
582
  try {
541
583
  const t = T.cfCall(dir, ["tunnels", "get", C.get("A2A_TUNNEL_ID")], { allowNotFound: true });
@@ -609,6 +651,7 @@ export async function status(o) {
609
651
  else {
610
652
  const rows = [
611
653
  ["config", s.configFile],
654
+ ["runtime", `Node ${s.runtime.node} (${s.runtime.execPath})${s.runtime.warning ? `; WARNING: ${s.runtime.warning}` : ""}`],
612
655
  ["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"],
613
656
  ["base URL", s.baseUrl ? `${s.baseUrl} (${s.baseUrlEnv ? `from the exported A2A_BASE_URL; ${s.configFile} has ${s.baseUrlSaved || "none"}` : s.urlKind})` : "(not saved)"],
614
657
  ];
@@ -636,6 +679,7 @@ export async function status(o) {
636
679
  const t = s.tunnel;
637
680
  rows.push(["tunnel", !t ? "none" : `${t.wakeUrl} -> ${t.origin || "?"}; ${!t.complete ? "INCOMPLETE" : t.tokenFileOk ? "set up" : "set up, but the connector token file is MISSING"}` +
638
681
  (t.connections != null ? `; ${tunnelStateLabel(t.state)}, ${t.connections} connector connection(s)` : t.error ? `; state unknown (${t.error})` : "")]);
682
+ if (t) rows.push(["connector", connectorLine(t)]);
639
683
  if (s.zones) rows.push(["zones", s.zones.length ? s.zones.join(", ") : "none on this account"]);
640
684
  const addr = s.addresses;
641
685
  if (addr) {
package/lib/tunnel.mjs CHANGED
@@ -156,7 +156,9 @@ export function connectorInstructions(tokenFile, token = "") {
156
156
  "Run the connector there (outbound TCP/UDP 7844 to Cloudflare must be allowed):",
157
157
  ` cloudflared tunnel run --token-file ${tokenFile} # cloudflared 2025.4+`,
158
158
  ` cloudflared tunnel run --token ${t}`,
159
- ` sudo cloudflared service install ${t} # as a system service (starts on boot)`,
159
+ ` sudo cloudflared service install ${t} # optional system service (starts on boot)`,
160
+ " On macOS, service install needs an admin password at a terminal; do not run it unattended.",
161
+ " Without admin access, keep the --token-file command in a terminal, or use a user LaunchAgent (untested).",
160
162
  token ? " (token shown because of --show-token: treat it like a password)"
161
163
  : ` The tunnel token is in ${tokenFile} (chmod 600). Copy that file to the agent's machine if it is another machine.`,
162
164
  ].join("\n");
@@ -373,6 +375,7 @@ export async function create(o) {
373
375
  console.error(`Then (Worker secrets apply within about 15 to 40 seconds; wait about 30 seconds): ${CLI} tunnel status and ${CLI} wake test`);
374
376
  if (!Object.keys(agentAuth).length && preset !== "generic")
375
377
  console.error(`note: no WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET in the environment; keep the ${preset} webhook's own auth too (export it and run \`wake set\`)`);
378
+ console.error("created: tunnel, DNS, Access app, token\nconnector: not running (start it now)\npersistent service: not installed (optional)");
376
379
  } catch (e) {
377
380
  console.error("==> rolling back what was created");
378
381
  await remove(o, { quiet: true, secretNames: createdSecrets }).catch((r) => console.error(`rollback incomplete: ${r.message}; re-run \`${CLI} tunnel rm\``));
package/lib/update.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // version and caches it; every run only reads that cache (no network call in the foreground). Off with
3
3
  // A2A_NO_UPDATE_CHECK=1, DO_NOT_TRACK=1, NO_UPDATE_NOTIFIER, CI, and when running from a source checkout.
4
4
  import fs from "node:fs";
5
+ import { CLI_COMMAND } from "./config.mjs";
5
6
  import os from "node:os";
6
7
  import path from "node:path";
7
8
  import { spawn } from "node:child_process";
@@ -31,7 +32,7 @@ export function newer(a, b) {
31
32
  /** The notice for `current`, given the cache contents (or "" when up to date / unknown). */
32
33
  export function notice(current, cache) {
33
34
  if (!cache || !cache.latest || !newer(cache.latest, current)) return "";
34
- return `a2a-exposed ${cache.latest} is available (you have ${current}): run npx -y a2a-exposed@latest deploy (updates the Worker; applies new D1 migrations). With a global install, npm i -g a2a-exposed@latest first. Silence: A2A_NO_UPDATE_CHECK=1`;
35
+ return `a2a-exposed ${cache.latest} is available (you have ${current}): run ${CLI_COMMAND} deploy (updates the Worker; applies new D1 migrations). With a global install, npm i -g a2a-exposed@latest first. Silence: A2A_NO_UPDATE_CHECK=1`;
35
36
  }
36
37
 
37
38
  export const due = (cache, now = Date.now()) => !cache || !(now - (cache.checkedAt || 0) < CHECK_INTERVAL_MS);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.41",
3
+ "version": "0.8.42",
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": {
@@ -834,7 +834,7 @@ const rpcSend: Rpc = async (params, label, version, ctx, ectx) => {
834
834
  const directReply = directReplyParams(params);
835
835
  const conf = params.configuration || {};
836
836
  const push = conf.taskPushNotificationConfig || conf.pushNotificationConfig;
837
- const pushCfg = push ? normPushCfg(push, version) : null;
837
+ const pushCfg = push ? { ...normPushCfg(push, version), _role: "message push" } : null;
838
838
  let task: Json = null;
839
839
  if (msg.taskId) {
840
840
  task = await ownedTask(ctx, msg.taskId, label);
@@ -896,7 +896,7 @@ const rpcPushSet: Rpc = async (p, label, version, ctx) => {
896
896
  else if (p.config && typeof p.config === "object") { taskId = p.taskId || String(p.parent || "").split("/").pop(); cfg = p.config; }
897
897
  else { taskId = p.taskId; cfg = { id: p.id, url: p.url, token: p.token, authentication: p.authentication }; }
898
898
  const t = await ownedTask(ctx, taskId, label);
899
- const c = normPushCfg(cfg, version);
899
+ const c = { ...normPushCfg(cfg, version), _role: "task push config" };
900
900
  t._push = [...t._push.filter((x: Json) => x.id !== c.id), c];
901
901
  await saveTaskStmt(ctx, t).run();
902
902
  return pubCfg(t.id, c, version);
@@ -1237,8 +1237,8 @@ async function sendPush(ctx: TenantContext, ectx: ExecutionContext, task: Json,
1237
1237
  try {
1238
1238
  const r = await doFetch(ctx, ectx, cfg.url, { method: "POST", headers, body: JSON.stringify(body), signal });
1239
1239
  return [r.status >= 200 && r.status < 300, `HTTP ${r.status}`];
1240
- } catch (e) {
1241
- return [false, String(e).slice(0, 200)];
1240
+ } catch {
1241
+ return [false, "push request failed"];
1242
1242
  }
1243
1243
  }
1244
1244
 
@@ -1250,8 +1250,9 @@ async function notifyPeer(ctx: TenantContext, ectx: ExecutionContext, t: Json, s
1250
1250
  ? await withDeadline(7000, (step) => sendPush(ctx, ectx, t, cfg, step), signal)
1251
1251
  : await sendPush(ctx, ectx, t, cfg);
1252
1252
  signal?.throwIfAborted();
1253
- results.push({ url: cfg.url, ok, info });
1254
- await histStmt(ctx, t.contextId, { dir: "out", peer: t._peer, taskId: t.id, event: "push", state: t.status.state, text: `${ok ? "ok" : "FAILED"} ${info}` }).run();
1253
+ const result = { role: cfg._role === "message push" ? "message push" : "task push config", host: new URL(cfg.url).host, fingerprint: await A.fingerprint(cfg.url), ok, info };
1254
+ results.push(result);
1255
+ await histStmt(ctx, t.contextId, { dir: "out", peer: t._peer, taskId: t.id, event: "push", state: t.status.state, text: `${result.role} ${result.host} [${result.fingerprint}]: ${ok ? "ok" : "FAILED"} ${info}`, data: result }).run();
1255
1256
  }
1256
1257
  return results;
1257
1258
  }
@@ -1264,6 +1265,7 @@ type ReplyDelivery = {
1264
1265
  target?: { alias?: string; host: string };
1265
1266
  httpStatus?: number;
1266
1267
  detail: string;
1268
+ push?: Json[];
1267
1269
  };
1268
1270
 
1269
1271
  const DIRECT_REPLY_META = "a2a.exposed/reply-delivery";
@@ -1695,12 +1697,13 @@ async function deliverReplyMessage(ctx: TenantContext, ectx: ExecutionContext, t
1695
1697
  if (task._push?.length) {
1696
1698
  const pushed = await notifyPeer(ctx, ectx, task, signal);
1697
1699
  const failed = pushed.find((x: Json) => !x.ok);
1698
- const target = safeTarget(undefined, String((failed || pushed[0])?.url || ""));
1700
+ const host = (failed || pushed[0])?.host;
1701
+ const target = host ? { host } : undefined;
1699
1702
  const info = String((failed || pushed[0])?.info || "");
1700
1703
  const http = /^HTTP (\d{3})$/.exec(info);
1701
1704
  const httpStatus = http && http[0] === info ? Number(http[1]) : undefined;
1702
1705
  return {
1703
- status: failed ? "delivery-failed" : "delivered", method: "push", taskId: task.id, state,
1706
+ status: failed ? "delivery-failed" : "delivered", method: "push", taskId: task.id, state, push: pushed,
1704
1707
  ...(target ? { target } : {}), ...(httpStatus !== undefined && httpStatus >= 100 && httpStatus <= 599 ? { httpStatus } : {}),
1705
1708
  detail: failed ? "A task push failed; the reply remains stored on the task." : "Delivered as the task's existing push update.",
1706
1709
  };
@@ -2123,7 +2126,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
2123
2126
  if (seg[2] === "working" && m === "POST") {
2124
2127
  if (A.TERMINAL.has(t.status.state)) return json({ error: `task is already ${t.status.state}` }, 409);
2125
2128
  t.status = { state: "working", timestamp: A.nowIso() };
2126
- await ctx.DB.batch([saveTaskStmt(ctx, t), histStmt(ctx, t.contextId, { dir: "local", taskId: t.id, event: "state", state: "working" })]);
2129
+ await ctx.DB.batch([saveTaskStmt(ctx, t), histStmt(ctx, t.contextId, { dir: "local", peer: t._peer, taskId: t.id, event: "state", state: "working" })]);
2127
2130
  await ackPendingWakes(ctx, [{ taskId: t.id, contextId: t.contextId }]);
2128
2131
  return json({ task: ownerTaskView(t), push: await notifyPeer(ctx, ectx, t) });
2129
2132
  }
@@ -2164,11 +2167,14 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
2164
2167
  if (raced?.result_json) return json({ task: ownerTaskView(await loadTask(ctx, t.id)), delivery: JSON.parse(raced.result_json), idempotent: true });
2165
2168
  return json({ error: "this reply is already being delivered; retry to read its result" }, 409);
2166
2169
  }
2170
+ const forced = !!b.force && A.TERMINAL.has(t.status.state);
2171
+ const previousState = t.status.state;
2167
2172
  if (state === "completed" || b.artifact) t.artifacts.push({ artifactId: A.newId(), name: b.artifactName || "response", parts: [{ kind: "text", text: b.text }] });
2168
2173
  t.history.push(msg);
2169
2174
  t.status = { state, timestamp: now, message: msg };
2170
2175
  await ctx.DB.batch([saveTaskStmt(ctx, t), msgStmt(ctx, t, msg),
2171
- histStmt(ctx, t.contextId, { dir: "out", peer: t._peer, taskId: t.id, role: "agent", state, text: b.text })]);
2176
+ histStmt(ctx, t.contextId, { dir: "out", peer: t._peer, taskId: t.id, role: "agent", state, text: b.text,
2177
+ ...(forced ? { event: "forced", data: { forced: true, previousState, state } } : {}) })]);
2172
2178
  await ackPendingWakes(ctx, [{ taskId: t.id, contextId: t.contextId }]);
2173
2179
  let delivery: ReplyDelivery;
2174
2180
  try { delivery = await withDeadline(18000, (signal) => deliverReplyMessage(ctx, ectx, t, msg, state, b.text, signal)); }
@@ -2192,7 +2198,13 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
2192
2198
  if (fromFilter !== null && !PEER_FILTER.test(fromFilter)) return json({ error: "from is invalid" }, 400);
2193
2199
  // Without `from`, rows for every peer that owns this context id (a task or an outbound task), plus local notes.
2194
2200
  // With `from`, one peer's rows only, so another peer's conversation is not in this read.
2195
- const rows = await ctx.DB.prepare(fromFilter ? `SELECT * FROM (
2201
+ const rows = await ctx.DB.prepare(`SELECT h.*,
2202
+ (SELECT status_json FROM tasks WHERE id = h.task_id AND context_id = h.context_id
2203
+ AND (h.peer IS NULL OR h.peer = '' OR peer = h.peer)) AS current_status_json,
2204
+ (SELECT json_object('state', state, 'timestamp', ts) FROM history latest
2205
+ WHERE latest.context_id = h.context_id AND latest.task_id = h.task_id AND latest.peer IS h.peer
2206
+ AND latest.state IS NOT NULL ORDER BY latest.seq DESC LIMIT 1) AS current_history_json
2207
+ FROM (${fromFilter ? `SELECT * FROM (
2196
2208
  SELECT * FROM history WHERE context_id = ? AND (
2197
2209
  peer = ?
2198
2210
  OR (
@@ -2213,10 +2225,12 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
2213
2225
  AND NOT EXISTS (SELECT 1 FROM outbound WHERE context_id = ?)
2214
2226
  )
2215
2227
  ) ORDER BY seq DESC LIMIT ?
2216
- ) ORDER BY seq`).bind(...(fromFilter
2228
+ ) ORDER BY seq`} ) h ORDER BY h.seq`).bind(...(fromFilter
2217
2229
  ? [seg[1], fromFilter, seg[1], fromFilter, seg[1], fromFilter, n]
2218
2230
  : [seg[1], seg[1], seg[1], seg[1], seg[1], n])).all();
2219
- return json((rows.results as Json[]).map((r) => ({ ...r, data: r.data_json ? JSON.parse(r.data_json) : undefined, data_json: undefined })));
2231
+ return json((rows.results as Json[]).map((r) => ({ ...r, data: r.data_json ? JSON.parse(r.data_json) : undefined, data_json: undefined,
2232
+ currentState: r.current_status_json ? (({ state, timestamp }) => ({ state, timestamp }))(JSON.parse(r.current_status_json))
2233
+ : r.current_history_json ? JSON.parse(r.current_history_json) : undefined, current_status_json: undefined, current_history_json: undefined })));
2220
2234
  }
2221
2235
  if (seg[0] === "history" && !seg[1] && m === "POST") {
2222
2236
  const b = await body();
package/worker/src/mcp.ts CHANGED
@@ -346,6 +346,10 @@ export const TOOLS: { name: string; title: string; description: string; inputSch
346
346
  status: str("Delivery outcome", { enum: ["delivered", "stored-only", "delivery-failed"] }),
347
347
  method: str("Path used", { enum: ["push", "a2a-message", "stored"] }),
348
348
  target: { type: "object", properties: { alias: str("Paired peer alias"), host: str("Destination host") }, required: ["host"], additionalProperties: false },
349
+ push: { type: "array", items: { type: "object", properties: {
350
+ role: str("Callback origin", { enum: ["task push config", "message push"] }), host: str("Callback host"),
351
+ fingerprint: str("First 12 hex characters of SHA-256 of the callback URL"), ok: { type: "boolean" }, info: str("Safe push result"),
352
+ }, required: ["role", "host", "fingerprint", "ok", "info"], additionalProperties: false } },
349
353
  httpStatus: { type: "integer", minimum: 100, maximum: 599 }, detail: str("Safe delivery summary"),
350
354
  }, required: ["taskId", "state", "status", "method", "detail"], additionalProperties: false } },
351
355
  { name: "send", title: "Send to peer", annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.41";
2
+ export const VERSION = "0.8.42";
@@ -159,3 +159,70 @@ test("1.0 list pages configs and 0.3 delete returns null", async (t) => {
159
159
  hides(del.text, token);
160
160
  assert.equal(s.logs.some((line) => line.includes(token)), false);
161
161
  });
162
+
163
+ test("push results identify callbacks without URLs, and forced replies preserve transitions", async (t) => {
164
+ const s = setup();
165
+ t.after(s.restore);
166
+ const originalFetch = globalThis.fetch;
167
+ t.after(() => { globalThis.fetch = originalFetch; });
168
+ const attempted: string[] = [];
169
+ globalThis.fetch = (async (url: any) => {
170
+ attempted.push(String(url));
171
+ return new Response(null, { status: String(url).includes("first") ? 401 : 200 });
172
+ }) as typeof fetch;
173
+ const ada = (await s.owner("POST", "/owner/peers", { label: "ada" })).token;
174
+ const first = "https://hooks.example.net/private/first?token=first-secret";
175
+ const second = "https://hooks.example.net/private/second?token=second-secret";
176
+ const sent = await s.call(ada, "message/send", {
177
+ message: { kind: "message", role: "user", messageId: crypto.randomUUID(), parts: [{ kind: "text", text: "hello" }] },
178
+ configuration: { pushNotificationConfig: { id: "message", url: first } },
179
+ });
180
+ const { id: taskId, contextId } = sent.data.result;
181
+ await s.call(ada, "tasks/pushNotificationConfig/set", { taskId, pushNotificationConfig: { id: "task", url: second } });
182
+ const working = await s.owner("POST", `/owner/tasks/${taskId}/working`, {});
183
+ assert.deepEqual(working.push.map((r: any) => [r.role, r.host, r.ok, r.info]), [
184
+ ["message push", "hooks.example.net", false, "HTTP 401"], ["task push config", "hooks.example.net", true, "HTTP 200"],
185
+ ]);
186
+ assert.deepEqual(working.push.map((r: any) => r.fingerprint), [(await sha256(first)).slice(0, 12), (await sha256(second)).slice(0, 12)]);
187
+ assert.doesNotMatch(JSON.stringify(working.push), /token=|first-secret|second-secret|private|https:/);
188
+ const done = await s.owner("POST", `/owner/tasks/${taskId}/reply`, { text: "done", state: "completed" });
189
+ assert.deepEqual(done.delivery.push, working.push);
190
+ assert.equal(done.task.status.state, "completed");
191
+ const blocked = await s.owner("POST", `/owner/tasks/${taskId}/reply`, { text: "question", state: "input-required" });
192
+ assert.match(blocked.error, /already completed/);
193
+ const forced = await s.owner("POST", `/owner/tasks/${taskId}/reply`, { text: "question", state: "input-required", force: true });
194
+ assert.equal(forced.task.status.state, "input-required");
195
+ const history = await s.owner("GET", `/owner/history/${contextId}?from=ada`);
196
+ const transitions = history.filter((r: any) => r.event === "forced");
197
+ assert.equal(transitions.length, 1);
198
+ assert.deepEqual(transitions[0].data, { forced: true, previousState: "completed", state: "input-required" });
199
+ assert.ok(history.every((r: any) => r.currentState.state === "input-required"));
200
+ assert.deepEqual(history.at(-1).currentState, { state: "input-required", timestamp: forced.task.status.timestamp });
201
+ assert.doesNotMatch(JSON.stringify(history), /first-secret|second-secret|private/);
202
+ const count = attempted.length;
203
+ const retry = await s.owner("POST", `/owner/tasks/${taskId}/reply`, { text: "question", state: "input-required", force: true });
204
+ assert.equal(retry.idempotent, true);
205
+ assert.equal(attempted.length, count);
206
+ const after = await s.owner("GET", `/owner/history/${contextId}?n=1&from=ada`);
207
+ assert.deepEqual(after[0].currentState, history.at(-1).currentState);
208
+ globalThis.fetch = (async () => { throw new Error(`failed ${first}`); }) as typeof fetch;
209
+ const failed = await s.owner("POST", `/owner/tasks/${taskId}/reply`, { text: "final", state: "completed" });
210
+ assert.doesNotMatch(JSON.stringify(failed.delivery), /first-secret|second-secret|private/);
211
+ assert.equal(failed.delivery.push[0].info, "push request failed");
212
+ });
213
+
214
+ test("limited outbound history reports the latest state for that peer only", async (t) => {
215
+ const s = setup();
216
+ t.after(s.restore);
217
+ const contextId = "outbound-history";
218
+ const taskId = "remote-task";
219
+ await s.owner("POST", "/owner/history", { contextId, taskId, peer: "ada", state: "working", ts: "2026-11-07T01:00:00.000Z" });
220
+ await s.owner("POST", "/owner/history", { contextId, taskId, peer: "bob", state: "failed", ts: "2026-11-07T01:01:00.000Z" });
221
+ await s.owner("POST", "/owner/history", { contextId, taskId, peer: "ada", event: "note", text: "latest note without a state" });
222
+ const rows = await s.owner("GET", `/owner/history/${contextId}?from=ada&n=1`);
223
+ assert.equal(rows.length, 1);
224
+ assert.equal(rows[0].event, "note");
225
+ assert.deepEqual(rows[0].currentState, { state: "working", timestamp: "2026-11-07T01:00:00.000Z" });
226
+ assert.equal(rows[0].current_history_json, undefined);
227
+ assert.equal(rows[0].current_status_json, undefined);
228
+ });