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