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 +2 -1
- package/lib/commands.mjs +17 -2
- package/lib/config.mjs +6 -1
- package/lib/deploy.mjs +14 -1
- package/lib/main.mjs +20 -4
- package/lib/pair.mjs +4 -4
- package/lib/runtime.mjs +54 -0
- package/lib/status.mjs +49 -5
- package/lib/tunnel.mjs +4 -1
- package/lib/update.mjs +2 -1
- package/package.json +1 -1
- package/worker/src/index.ts +27 -13
- package/worker/src/mcp.ts +4 -0
- package/worker/src/version.ts +1 -1
- package/worker/test/push-config.test.ts +67 -0
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 =
|
|
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) || [])
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 \`${
|
|
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 ? ` (\`${
|
|
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 ? `: \`${
|
|
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 "..."`);
|
package/lib/runtime.mjs
ADDED
|
@@ -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
|
|
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 \`${
|
|
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 \`${
|
|
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} #
|
|
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
|
|
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
package/worker/src/index.ts
CHANGED
|
@@ -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
|
|
1241
|
-
return [false,
|
|
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
|
-
|
|
1254
|
-
|
|
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
|
|
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(
|
|
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 },
|
package/worker/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Inbox build. Keep this equal to `version` in cli/package.json. */
|
|
2
|
-
export const VERSION = "0.8.
|
|
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
|
+
});
|