a2a-exposed 0.0.0-stage → 0.4.0

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/status.mjs ADDED
@@ -0,0 +1,198 @@
1
+ // `status`: where a setup stands and what to do next. Safe to run any time (read-only: GET requests and cf list/get
2
+ // calls only), so an agent that was interrupted can resume from the printed next step. The CLI fetches the agent card
3
+ // itself, so agents need no curl (some agent sandboxes flag *.dev URLs in shell commands).
4
+ import fs from "node:fs";
5
+ import * as C from "./config.mjs";
6
+ import { cardUrlProblem, cfErrorCode, describeHttp, fingerprint, httpJson } from "./a2a.mjs";
7
+ import { baseUrl, owner } from "./commands.mjs";
8
+ import * as D from "./deploy.mjs";
9
+ import * as T from "./tunnel.mjs";
10
+
11
+ const CLI = "a2a-exposed";
12
+ /** Presets whose webhook usually listens only locally: wakes need the tunnel (or another reverse proxy). */
13
+ export const LOCAL_PRESETS = ["openclaw-wake", "openclaw-agent", "hermes"];
14
+
15
+ /** Agent card check: { ok, name, versions, urls, urlProblem } or { ok: false, error }. urlProblem is "" when every
16
+ * endpoint URL in the card is the base URL (peers send requests there). */
17
+ export async function checkCard(base) {
18
+ try {
19
+ const { status, data } = await httpJson(`${base}/.well-known/agent-card.json`, { timeout: 15000 });
20
+ if (status !== 200 || !data || typeof data !== "object" || !data.name) {
21
+ const cf = cfErrorCode(typeof data === "string" ? data : "");
22
+ return { ok: false, error: status === 200 ? "HTTP 200 (no agent card)" : describeHttp(status, data), ...(cf ? { cfError: cf } : {}) };
23
+ }
24
+ return { ok: true, name: data.name, versions: (data.supportedInterfaces || []).map((i) => i.protocolVersion).filter(Boolean),
25
+ urls: [...new Set((data.supportedInterfaces || []).map((i) => i && i.url).filter(Boolean))], urlProblem: cardUrlProblem(data, base) };
26
+ } catch (e) { return { ok: false, error: e.message }; }
27
+ }
28
+
29
+ /** One line describing how wakes reach the agent. */
30
+ export function wakeLine(s) {
31
+ if (!s.wake) return "unknown (owner API not reachable)";
32
+ const w = s.wake;
33
+ if (!w.configured) return "none: no WAKE_WEBHOOK_URL on the Worker, so the agent is expected to poll the inbox";
34
+ const auth = [w.hasKey && "bearer/API key", w.hasHmacSecret && "HMAC signature", w.hasAccessServiceToken && "Access service token"].filter(Boolean);
35
+ return `webhook (preset ${w.preset}${s.tunnel ? ", through the tunnel" : ""}); auth: ${auth.join(" + ") || "none"}; URL fingerprint ${w.urlFingerprint || "-"}`;
36
+ }
37
+
38
+ /** The single most useful next step for a status snapshot (pure; unit-tested). `also`: optional extra hints, printed
39
+ * as separate lines (so the next step stays one short instruction). */
40
+ export function nextStep(s) {
41
+ const fail = (text) => ({ ok: false, text, also: [] });
42
+ const done = (text, also = []) => ({ ok: true, text, also });
43
+ if (!s.deployed) return fail(`nothing is deployed from ${s.configFile}: run \`${CLI} init ...\` (setup skill, section 2). Another bot's deployment? Set A2A_CONFIG_DIR.`);
44
+ if (s.baseUrlEnv)
45
+ return fail(`A2A_BASE_URL is exported as ${s.baseUrlEnv}, but ${s.configFile} has ${s.baseUrlSaved || "(none)"}: CLI commands (and push URLs sent to peers) use the exported value. Unset A2A_BASE_URL (the inbox URL is always the deployment's own)`);
46
+ if (!s.baseUrl || !s.hasOwnerToken)
47
+ return fail(`the deployment is incomplete (no ${!s.baseUrl ? "base URL" : "owner token"} saved): re-run \`${CLI} init\` with the same flags (safe: it reuses the D1 database and the owner token)`);
48
+ if (!s.card.ok && s.card.cfError === 1042)
49
+ return fail(`the Worker is still propagating (Cloudflare error 1042, normal for up to ~30 s after a first deploy): run \`${CLI} status\` again in 30 seconds. Still 1042 after a few minutes? Run \`${CLI} deploy\``);
50
+ if (!s.card.ok)
51
+ return fail(`the agent card is not reachable (${s.card.error}). A new workers.dev subdomain or custom domain can take a few minutes: run \`${CLI} status\` again. Still failing? Run \`${CLI} deploy\``);
52
+ if (s.card.urlProblem)
53
+ return fail(`the agent card does not point peers at this deployment: ${s.card.urlProblem}, so peers would send requests there. Run \`${CLI} deploy\`: the card URL comes from the deployment (custom hostname or workers.dev), never from a local webhook, Tailnet or tunnel URL; don't edit the card by hand`);
54
+ if (!s.ownerApi.ok)
55
+ return fail(/HTTP 401/.test(s.ownerApi.error)
56
+ ? `the owner token in ${s.configFile} does not match the Worker: run \`${CLI} init --rotate-owner-token\` from the machine that owns this deployment`
57
+ : `the owner API failed (${s.ownerApi.error}): run \`${CLI} deploy\``);
58
+ // proxy / expose mode: the upstream answers peers, so wakes are optional (pairing notifications only)
59
+ const fc = s.facade;
60
+ if (fc) {
61
+ if (fc.mode !== "proxy")
62
+ return fail(`${s.configFile} has A2A_UPSTREAM_URL, but the Worker is not in proxy mode (an older template or an interrupted deploy): run \`${CLI} deploy\``);
63
+ if (fc.upstreamProblem) return fail(`the upstream URL is unusable: ${fc.upstreamProblem}. Fix it with \`${CLI} deploy --upstream <tunnel URL>\``);
64
+ if (fc.upstreamCardProblem) return fail(`the upstream card URL is refused: ${fc.upstreamCardProblem}. Fix it with \`${CLI} deploy --upstream-card-url <URL on the upstream's origin>\` (or \`--upstream-card-url none\` for the default)`);
65
+ if (fc.upstreamCard !== "ok")
66
+ return fail(`the Worker can't fetch the upstream's agent card (${fc.upstreamCard}): is the tunnel connector running on the agent's machine, and does the Access app admit the service token you exported as UPSTREAM_ACCESS_CLIENT_ID / UPSTREAM_ACCESS_CLIENT_SECRET (re-run \`${CLI} deploy\` with them exported)?`);
67
+ if (fc.publicCardLeaks && fc.publicCardLeaks.length)
68
+ return fail(`the public card still names a private URL (${fc.publicCardLeaks[0]}): report this as a bug; meanwhile override the field with --agent-description / --agent-skills and run \`${CLI} deploy\``);
69
+ const also = [];
70
+ if (!fc.hasUpstreamAccessServiceToken) also.push("no Access service token for the upstream: unless the upstream hostname is protected another way, put it behind Cloudflare Access and export UPSTREAM_ACCESS_CLIENT_ID / UPSTREAM_ACCESS_CLIENT_SECRET, then deploy");
71
+ if (s.pairing && s.pairing.mode === "human" && !s.pairing.passwordSet)
72
+ 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`);
73
+ 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)`);
74
+ return done(`none: the façade is up and forwards paired peers' A2A calls to the upstream. Peers connect with \`${CLI} connect ${s.baseUrl}\``, also);
75
+ }
76
+ const t = s.tunnel;
77
+ if (t && !t.complete) return fail(`a previous \`tunnel create\` did not finish: run \`${CLI} tunnel rm\`, then \`${CLI} tunnel create\``);
78
+ if (t && !t.tokenFileOk)
79
+ return fail(`the tunnel's connector token file (${t.tokenFile}) is missing or empty: run \`${CLI} tunnel create\` again (it downloads the token again; nothing new is created)`);
80
+ const agentSecret = s.wake.preset === "hermes" ? "WAKE_HMAC_SECRET" : "WAKE_WEBHOOK_KEY";
81
+ const needsAgentSecret = LOCAL_PRESETS.includes(s.wake.preset) && !s.wake.hasKey && !s.wake.hasHmacSecret;
82
+ if (t && !(s.wake.hasAccessServiceToken && t.workerUrlMatches))
83
+ return fail(`the Worker does not have the tunnel's wake secrets: ${needsAgentSecret ? `export ${agentSecret} and ` : ""}run \`${CLI} tunnel create\` again (it re-uploads them; nothing new is created)`);
84
+ if (t && t.connections === 0)
85
+ return fail(`the tunnel has no running connector: on the agent's machine run \`cloudflared tunnel run --token-file ${t.tokenFile}\` (outbound port 7844 must be open), then \`${CLI} wake test\``);
86
+ if (s.wake.configured && needsAgentSecret)
87
+ return fail(`the Worker sends no webhook auth, so the ${s.wake.preset} webhook will reject wakes: export ${agentSecret} (${s.wake.preset === "hermes" ? "the route secret" : "the hooks token"}), then run \`${CLI} ${t ? "tunnel create" : "wake set"}\``);
88
+ // not broken, but peers can't pair until the human sets the approval password
89
+ const also = [];
90
+ if (s.pairing && s.pairing.mode === "human" && !s.pairing.passwordSet)
91
+ 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`);
92
+ if (s.pairing && s.pairing.pending)
93
+ also.push(`${s.pairing.pending} pending pairing request(s): \`${CLI} pair list\`, then tell your human (never approve on your own)`);
94
+ const done2 = (text, extra = []) => done(text, [...extra, ...also]);
95
+ if (!s.wake.configured) {
96
+ const poll = "the agent must check the inbox on a schedule (setup skill: polling; Hermes and OpenClaw have copy-paste commands there)";
97
+ if (s.wake.preset && !LOCAL_PRESETS.includes(s.wake.preset) && s.wake.preset !== "generic")
98
+ return done2(`no wake webhook: export WAKE_WEBHOOK_URL and WAKE_WEBHOOK_KEY, then run \`${CLI} wake set --preset ${s.wake.preset}\`; or ${poll}`);
99
+ const z = s.zones;
100
+ const tunnel = !z ? `a local-only webhook can use \`${CLI} tunnel create\` (needs a zone on the account)`
101
+ : !z.length ? "the account has no domain (zone), so the secure tunnel is not available"
102
+ : z.length === 1 ? `for immediate wakes to a local-only webhook, run \`${CLI} tunnel create\` (it uses the account's zone ${z[0]}; no redeploy needed)`
103
+ : `for immediate wakes to a local-only webhook, run \`${CLI} tunnel create --tunnel-zone <zone>\` with one of: ${z.join(", ")} (no redeploy needed)`;
104
+ const pub = s.wake.preset === "generic" ? `a public webhook: export WAKE_WEBHOOK_URL (and a key), then \`${CLI} wake set\`` : "";
105
+ return done2(`no wake webhook, so ${poll}.`, [`optional: ${tunnel}`, ...(pub ? [`optional: ${pub}`] : [])]);
106
+ }
107
+ return done2(`none: setup is complete. Check a real wake with \`${CLI} wake test\`.`,
108
+ [`peers connect with \`${CLI} connect ${s.baseUrl}\` (your human approves each one); \`${CLI} token issue <peer>\` is the manual fallback`]);
109
+ }
110
+
111
+ /** Collect the snapshot. Each probe tolerates failure; nothing is created or changed. */
112
+ export async function collect() {
113
+ const s = {
114
+ configFile: C.CONFIG_FILE,
115
+ deployed: !!(C.get("A2A_D1_ID") && C.get("A2A_WORKER_NAME")),
116
+ worker: C.get("A2A_WORKER_NAME"), d1: C.get("A2A_D1_NAME"), account: C.get("CLOUDFLARE_ACCOUNT_ID"), cfProfile: C.get("CF_PROFILE"),
117
+ baseUrl: baseUrl(), urlKind: C.get("A2A_HOSTNAME") ? "custom domain" : "workers.dev", cron: C.get("A2A_ENABLE_CRON") === "1",
118
+ hasOwnerToken: !!C.get("A2A_OWNER_TOKEN"), baseUrlSaved: (C.fileConfig().A2A_BASE_URL || "").replace(/\/$/, ""), baseUrlEnv: "",
119
+ card: { ok: false, error: "not checked" }, ownerApi: { ok: false, error: "not checked" }, wake: null, tunnel: null, zones: null, pairing: null,
120
+ upstream: C.get("A2A_UPSTREAM_URL") || null, facade: null,
121
+ };
122
+ const envBase = (process.env.A2A_BASE_URL || "").replace(/\/$/, "");
123
+ if (envBase && envBase !== s.baseUrlSaved) s.baseUrlEnv = envBase;
124
+ if (!s.deployed || !s.baseUrl) return s;
125
+ s.card = await checkCard(s.baseUrl);
126
+ if (s.hasOwnerToken) {
127
+ try {
128
+ const p = await owner("GET", "/owner/wake/preview");
129
+ s.ownerApi = { ok: true };
130
+ s.wake = { preset: p.preset, configured: !!p.configured, hasKey: !!p.hasKey, hasHmacSecret: !!p.hasHmacSecret,
131
+ hasAccessServiceToken: !!p.hasAccessServiceToken, urlFingerprint: p.fingerprints?.url || null };
132
+ } catch (e) { s.ownerApi = { ok: false, error: e.message }; }
133
+ if (s.ownerApi.ok) {
134
+ try {
135
+ const pr = await owner("GET", "/owner/pairing");
136
+ if (pr && typeof pr === "object" && ["human", "agent", "off"].includes(pr.mode))
137
+ s.pairing = { mode: pr.mode, passwordSet: !!pr.passwordSet, passwordSetAt: pr.passwordSetAt || null, passwordSetVia: pr.passwordSetVia || null,
138
+ setupLinkExpiresAt: pr.setupLinkExpiresAt || null, pending: (pr.pending || []).length };
139
+ } catch { /* older Worker (no pairing yet): leave unknown */ }
140
+ if (s.upstream) {
141
+ try { s.facade = await owner("GET", "/owner/facade"); }
142
+ catch (e) { s.facade = { mode: "unknown", error: e.message }; }
143
+ }
144
+ }
145
+ }
146
+ const dir = D.workerDir({});
147
+ const haveCf = fs.existsSync(dir);
148
+ const host = C.get("A2A_TUNNEL_HOSTNAME");
149
+ if (host || C.get("A2A_TUNNEL_ID")) {
150
+ const url = T.wakeUrl(host || "?", C.get("A2A_TUNNEL_PATH") || "/");
151
+ s.tunnel = { hostname: host, wakeUrl: url, origin: C.get("A2A_TUNNEL_ORIGIN"), complete: T.tunnelComplete(), tokenFileOk: T.tokenFileOk(),
152
+ tokenFile: C.get("A2A_TUNNEL_TOKEN_FILE") || "<config dir>/tunnel-token", workerUrlMatches: s.wake?.urlFingerprint === fingerprint(url), connections: null };
153
+ if (haveCf && C.get("A2A_TUNNEL_ID")) {
154
+ try {
155
+ const t = T.cfCall(dir, ["tunnels", "get", C.get("A2A_TUNNEL_ID")], { allowNotFound: true });
156
+ if (!t) s.tunnel.complete = false;
157
+ else { s.tunnel.state = t.status; s.tunnel.connections = (t.connections || []).length; }
158
+ } catch (e) { s.tunnel.error = e.message; }
159
+ }
160
+ } else if (s.wake && !s.wake.configured && haveCf) {
161
+ try { s.zones = T.listZones(dir).filter((z) => !z.status || z.status === "active").map((z) => z.name); } catch { /* not logged in, offline: leave unknown */ }
162
+ }
163
+ return s;
164
+ }
165
+
166
+ export async function status(o) {
167
+ const s = await collect();
168
+ const next = nextStep(s);
169
+ if (o.json) console.log(JSON.stringify({ ...s, nextStep: next.text, also: next.also, ok: next.ok }, null, 2));
170
+ else {
171
+ const rows = [
172
+ ["config", s.configFile],
173
+ ["deployment", 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"],
174
+ ["base URL", s.baseUrl ? `${s.baseUrl} (${s.baseUrlEnv ? `from the exported A2A_BASE_URL; ${s.configFile} has ${s.baseUrlSaved || "none"}` : s.urlKind})` : "(not saved)"],
175
+ ];
176
+ if (s.deployed && s.baseUrl) {
177
+ rows.push(["agent card", !s.card.ok ? `FAILED: ${s.card.error}` : s.card.urlProblem ? `WRONG URL: "${s.card.name}", but ${s.card.urlProblem}`
178
+ : `OK: "${s.card.name}" (A2A ${s.card.versions.join(", ") || "?"})`]);
179
+ rows.push(["owner API", s.ownerApi.ok ? "OK" : `FAILED: ${s.ownerApi.error}`]);
180
+ if (s.upstream) {
181
+ const f = s.facade || {};
182
+ rows.push(["upstream", `${s.upstream} (proxy mode)${f.mode === "proxy" ? `; card ${f.upstreamCard}${f.upstreamVersions && f.upstreamVersions.length ? ` (A2A ${f.upstreamVersions.join(", ")})` : ""}; credential ${f.hasUpstreamToken ? "set" : "none"}; Access service token ${f.hasUpstreamAccessServiceToken ? "set" : "NONE"}; public card ${f.publicCardLeaks && f.publicCardLeaks.length ? "LEAKS a private URL" : "clean"}` : f.error ? `; ${f.error}` : "; the Worker is not in proxy mode"}`]);
183
+ }
184
+ rows.push(["wake", wakeLine(s)]);
185
+ const t = s.tunnel;
186
+ 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"}` +
187
+ (t.connections != null ? `; ${t.state || "?"}, ${t.connections} connector connection(s)` : t.error ? `; state unknown (${t.error})` : "")]);
188
+ if (s.zones) rows.push(["zones", s.zones.length ? s.zones.join(", ") : "none on this account"]);
189
+ const pg = s.pairing;
190
+ if (pg) rows.push(["pairing", pg.mode === "off" ? "off (token issue only)"
191
+ : `${pg.mode} approval; approval password ${pg.passwordSet ? `set${pg.passwordSetAt ? ` ${pg.passwordSetAt}` : ""}${pg.passwordSetVia ? ` (via ${pg.passwordSetVia})` : ""}` : `NOT SET${pg.setupLinkExpiresAt ? ` (a setup link is open until ${pg.setupLinkExpiresAt})` : ""}`}${pg.pending ? `; ${pg.pending} pending request(s): ${CLI} pair list` : ""}`]);
192
+ }
193
+ rows.push(["next step", next.text]);
194
+ for (const a of next.also || []) rows.push(["also", a]);
195
+ for (const [k, v] of rows) console.log(`${(k + ":").padEnd(12)} ${v}`);
196
+ }
197
+ if (!next.ok) process.exitCode = 1;
198
+ }