a2a-exposed 0.0.0-stage → 0.4.1

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,314 @@
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, die, fingerprint, httpJson } from "./a2a.mjs";
7
+ import { baseUrl, owner, ownerTry } 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
+ // ---------------------------------------------------------------- proxy mode: upstream diagnosis
39
+ /** What to do for each reason code of the Worker's upstream check (`POST /owner/facade/verify`, Worker log
40
+ * `upstream_error`). `d` is the check's detail line (HTTP status, Cloudflare error; never a secret). */
41
+ export const UPSTREAM_FIX = {
42
+ access_credentials_missing: (d) => `Cloudflare Access refused the Worker (${d}). Export UPSTREAM_ACCESS_CLIENT_ID / UPSTREAM_ACCESS_CLIENT_SECRET (the service token the upstream's Access app admits) and run \`${CLI} deploy\``,
43
+ access_rejected: (d) => `Cloudflare Access refused the Worker's service token (${d}): the Access app on the upstream hostname needs a Service Auth policy for that token, and UPSTREAM_ACCESS_CLIENT_ID / _SECRET must be its current pair (export them and run \`${CLI} deploy\`)`,
44
+ upstream_auth_missing: (d) => `Access let the Worker through, but the agent refused the call (${d}). Export UPSTREAM_TOKEN (the bearer the agent expects; its card advertises it) and run \`${CLI} deploy\` (or pipe it: \`<print token> | ${CLI} deploy --upstream-token-stdin\`)`,
45
+ upstream_auth_rejected: (d) => `Access let the Worker through, but the agent refused its UPSTREAM_TOKEN (${d}): wrong or rotated. Export the current one and run \`${CLI} deploy\``,
46
+ tunnel_down: (d) => `Cloudflare can't reach the upstream tunnel (${d}): start the connector on the agent's machine (\`cloudflared tunnel run ...\`)`,
47
+ upstream_unavailable: (d) => `the tunnel answers but the agent's A2A server behind it does not (${d}): start the agent, and check the tunnel ingress \`service:\` URL`,
48
+ network: (d) => `the Worker could not connect to the upstream (${d}): check the tunnel hostname (DNS record, certificate)`,
49
+ unexpected_response: (d) => `the upstream answered without JSON-RPC (${d}): --upstream must be the agent's JSON-RPC endpoint (not its web UI or card URL); fix it with \`${CLI} deploy --upstream <URL>\``,
50
+ misconfigured: (d) => `the upstream URL is unusable (${d}): fix it with \`${CLI} deploy --upstream <tunnel URL>\``,
51
+ };
52
+
53
+ /** The upstream check as layer-by-layer rows: façade peer auth (public), Cloudflare Access, the agent's bearer check, the
54
+ * A2A app. `v`: the Worker's verify answer; `fa`: the public peer-auth probe ({ status }) or null. Pure; unit-tested. */
55
+ export function verifyLayers(v, fa = null) {
56
+ const rows = [];
57
+ if (fa) rows.push(["façade peer auth", fa.status === 401 ? "enforced (an unauthenticated call gets 401 from the façade)"
58
+ : fa.error ? `not checked (${fa.error})` : `UNEXPECTED: an unauthenticated call got HTTP ${fa.status} (expected 401)`]);
59
+ if (!v) return rows;
60
+ const r = v.reason, sent = v.sent || {};
61
+ const notReached = "not reached";
62
+ const access = r === "access_credentials_missing" ? "FAILED: refused by Cloudflare Access; the Worker has no Access service token"
63
+ : r === "access_rejected" ? "FAILED: Cloudflare Access refused the Worker's service token (policy or wrong token)"
64
+ : ["upstream_auth_missing", "upstream_auth_rejected", "reachable", "upstream_unavailable"].includes(r) ? (sent.accessServiceToken ? "passed" : "no service token sent (the upstream hostname is not behind Access?)")
65
+ : r === "tunnel_down" || r === "network" ? notReached : "?";
66
+ const bearer = r === "upstream_auth_missing" ? "FAILED: the agent wants a credential; the Worker has no UPSTREAM_TOKEN"
67
+ : r === "upstream_auth_rejected" ? "FAILED: the agent refused the Worker's UPSTREAM_TOKEN"
68
+ : r === "reachable" ? (sent.bearer ? "accepted" : "none sent (the agent accepted the call without one)")
69
+ : notReached;
70
+ const app = r === "reachable" ? `reachable (${v.detail}${v.rpcErrorCode === -32601 ? ": method not found, as expected" : ""}; no task created)`
71
+ : ["tunnel_down", "upstream_unavailable", "network", "unexpected_response", "misconfigured"].includes(r) ? `FAILED: ${v.detail}` : notReached;
72
+ rows.push(["Access", access], ["upstream bearer", bearer], ["A2A app", app]);
73
+ return rows;
74
+ }
75
+
76
+ /** Peer token variables set in the environment that shadow a different token saved in config.env (names only). */
77
+ export function shadowedPeerTokens() {
78
+ return [...new Set(Object.values(C.loadPeers()).map((p) => p && p.token_env).filter(Boolean))].filter((k) => C.envOverridesFile(k));
79
+ }
80
+
81
+ /** The single most useful next step for a status snapshot (pure; unit-tested). `also`: optional extra hints, printed
82
+ * as separate lines (so the next step stays one short instruction). */
83
+ export function nextStep(s) {
84
+ const n = nextStepCore(s);
85
+ 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`);
86
+ return sh.length ? { ...n, also: [...n.also, ...sh] } : n;
87
+ }
88
+
89
+ function nextStepCore(s) {
90
+ const fail = (text) => ({ ok: false, text, also: [] });
91
+ const done = (text, also = []) => ({ ok: true, text, also });
92
+ 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.`);
93
+ if (s.baseUrlEnv)
94
+ 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)`);
95
+ if (!s.baseUrl || !s.hasOwnerToken)
96
+ 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)`);
97
+ if (!s.card.ok && s.card.cfError === 1042)
98
+ 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\``);
99
+ if (!s.card.ok)
100
+ 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\``);
101
+ if (s.card.urlProblem)
102
+ 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`);
103
+ if (!s.ownerApi.ok)
104
+ return fail(/HTTP 401/.test(s.ownerApi.error)
105
+ ? `the owner token in ${s.configFile} does not match the Worker: run \`${CLI} init --rotate-owner-token\` from the machine that owns this deployment`
106
+ : `the owner API failed (${s.ownerApi.error}): run \`${CLI} deploy\``);
107
+ // proxy / expose mode: the upstream answers peers, so wakes are optional (pairing notifications only)
108
+ const fc = s.facade;
109
+ if (fc) {
110
+ if (fc.mode !== "proxy")
111
+ 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\``);
112
+ if (fc.upstreamProblem) return fail(`the upstream URL is unusable: ${fc.upstreamProblem}. Fix it with \`${CLI} deploy --upstream <tunnel URL>\``);
113
+ 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)`);
114
+ // the live check (owner-authenticated, run by the Worker with its stored secrets) is the precise diagnosis
115
+ const v = s.upstreamVerify;
116
+ if (v && v.reason && v.reason !== "reachable" && UPSTREAM_FIX[v.reason]) return fail(UPSTREAM_FIX[v.reason](v.detail || v.reason));
117
+ // an Access-only configuration must not look complete when the agent's card asks for a bearer
118
+ const wantsBearer = fc.upstreamAuth && fc.upstreamAuth.bearer === true;
119
+ if (wantsBearer && !fc.hasUpstreamToken && !(v && v.ok) && !s.upstreamNoToken)
120
+ return fail(`the upstream's card asks for a bearer token (${(fc.upstreamAuth.schemes || []).join(", ")}) and the Worker has no UPSTREAM_TOKEN: Cloudflare Access only lets the Worker through the tunnel, so peers' calls would fail with HTTP 502. Export UPSTREAM_TOKEN and run \`${CLI} deploy\``);
121
+ if (fc.upstreamCard !== "ok")
122
+ 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)?`);
123
+ if (fc.publicCardLeaks && fc.publicCardLeaks.length)
124
+ 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\``);
125
+ const also = [];
126
+ 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");
127
+ if (wantsBearer && !fc.hasUpstreamToken)
128
+ also.push(`the upstream's card asks for a bearer token but the Worker has none${s.upstreamNoToken ? " (--no-upstream-token)" : ""}${v && v.ok ? "; the agent accepts calls without it today" : ""}: if peers get HTTP 502, export UPSTREAM_TOKEN and run \`${CLI} deploy\``);
129
+ if (fc.upstreamAuth && fc.upstreamAuth.unsupported && fc.upstreamAuth.unsupported.length)
130
+ also.push(`the upstream's card requires ${fc.upstreamAuth.unsupported.join(", ")}; the façade only presents a bearer token (UPSTREAM_TOKEN) and the Access service token`);
131
+ if (s.localUpstreamToken && fc.fingerprints && fc.fingerprints.upstreamToken !== s.localUpstreamToken)
132
+ also.push(`the UPSTREAM_TOKEN exported here (fingerprint ${s.localUpstreamToken}) differs from the Worker's (${fc.fingerprints.upstreamToken || "none"}): run \`${CLI} deploy\` to upload it`);
133
+ if (!v && s.upstreamVerifyError) also.push(`the live upstream check was not run (${s.upstreamVerifyError || "unavailable"})${/HTTP 404/.test(s.upstreamVerifyError || "") ? `: run \`${CLI} deploy\` for a Worker with \`upstream verify\`` : ""}`);
134
+ if (fc.lastFailure && fc.lastFailure.reason && v && v.ok)
135
+ also.push(`last failed peer call: ${fc.lastFailure.reason}${fc.lastFailure.status ? ` (HTTP ${fc.lastFailure.status})` : ""} at ${fc.lastFailure.at}; the live check passes now`);
136
+ if (s.pairing && s.pairing.mode === "human" && !s.pairing.passwordSet)
137
+ 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`);
138
+ 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)`);
139
+ 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);
140
+ }
141
+ const t = s.tunnel;
142
+ if (t && !t.complete) return fail(`a previous \`tunnel create\` did not finish: run \`${CLI} tunnel rm\`, then \`${CLI} tunnel create\``);
143
+ if (t && !t.tokenFileOk)
144
+ 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)`);
145
+ const agentSecret = s.wake.preset === "hermes" ? "WAKE_HMAC_SECRET" : "WAKE_WEBHOOK_KEY";
146
+ const needsAgentSecret = LOCAL_PRESETS.includes(s.wake.preset) && !s.wake.hasKey && !s.wake.hasHmacSecret;
147
+ if (t && !(s.wake.hasAccessServiceToken && t.workerUrlMatches))
148
+ 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)`);
149
+ if (t && t.connections === 0)
150
+ 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\``);
151
+ if (s.wake.configured && needsAgentSecret)
152
+ 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"}\``);
153
+ // not broken, but peers can't pair until the human sets the approval password
154
+ const also = [];
155
+ if (s.pairing && s.pairing.mode === "human" && !s.pairing.passwordSet)
156
+ 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`);
157
+ if (s.pairing && s.pairing.pending)
158
+ also.push(`${s.pairing.pending} pending pairing request(s): \`${CLI} pair list\`, then tell your human (never approve on your own)`);
159
+ const done2 = (text, extra = []) => done(text, [...extra, ...also]);
160
+ if (!s.wake.configured) {
161
+ const poll = "the agent must check the inbox on a schedule (setup skill: polling; Hermes and OpenClaw have copy-paste commands there)";
162
+ if (s.wake.preset && !LOCAL_PRESETS.includes(s.wake.preset) && s.wake.preset !== "generic")
163
+ return done2(`no wake webhook: export WAKE_WEBHOOK_URL and WAKE_WEBHOOK_KEY, then run \`${CLI} wake set --preset ${s.wake.preset}\`; or ${poll}`);
164
+ const z = s.zones;
165
+ const tunnel = !z ? `a local-only webhook can use \`${CLI} tunnel create\` (needs a zone on the account)`
166
+ : !z.length ? "the account has no domain (zone), so the secure tunnel is not available"
167
+ : 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)`
168
+ : `for immediate wakes to a local-only webhook, run \`${CLI} tunnel create --tunnel-zone <zone>\` with one of: ${z.join(", ")} (no redeploy needed)`;
169
+ const pub = s.wake.preset === "generic" ? `a public webhook: export WAKE_WEBHOOK_URL (and a key), then \`${CLI} wake set\`` : "";
170
+ return done2(`no wake webhook, so ${poll}.`, [`optional: ${tunnel}`, ...(pub ? [`optional: ${pub}`] : [])]);
171
+ }
172
+ return done2(`none: setup is complete. Check a real wake with \`${CLI} wake test\`.`,
173
+ [`peers connect with \`${CLI} connect ${s.baseUrl}\` (your human approves each one); \`${CLI} token issue <peer>\` is the manual fallback`]);
174
+ }
175
+
176
+ /** Collect the snapshot. Each probe tolerates failure; nothing is created or changed. */
177
+ export async function collect() {
178
+ const s = {
179
+ configFile: C.CONFIG_FILE,
180
+ deployed: !!(C.get("A2A_D1_ID") && C.get("A2A_WORKER_NAME")),
181
+ worker: C.get("A2A_WORKER_NAME"), d1: C.get("A2A_D1_NAME"), account: C.get("CLOUDFLARE_ACCOUNT_ID"), cfProfile: C.get("CF_PROFILE"),
182
+ baseUrl: baseUrl(), urlKind: C.get("A2A_HOSTNAME") ? "custom domain" : "workers.dev", cron: C.get("A2A_ENABLE_CRON") === "1",
183
+ hasOwnerToken: !!C.get("A2A_OWNER_TOKEN"), baseUrlSaved: (C.fileConfig().A2A_BASE_URL || "").replace(/\/$/, ""), baseUrlEnv: "",
184
+ card: { ok: false, error: "not checked" }, ownerApi: { ok: false, error: "not checked" }, wake: null, tunnel: null, zones: null, pairing: null,
185
+ upstream: C.get("A2A_UPSTREAM_URL") || null, facade: null, upstreamVerify: null, upstreamVerifyError: "", facadePeerAuth: null,
186
+ upstreamNoToken: C.get("A2A_UPSTREAM_NO_TOKEN") === "1", localUpstreamToken: fingerprint(process.env.UPSTREAM_TOKEN),
187
+ peerTokenShadowed: shadowedPeerTokens(),
188
+ };
189
+ const envBase = (process.env.A2A_BASE_URL || "").replace(/\/$/, "");
190
+ if (envBase && envBase !== s.baseUrlSaved) s.baseUrlEnv = envBase;
191
+ if (!s.deployed || !s.baseUrl) return s;
192
+ s.card = await checkCard(s.baseUrl);
193
+ if (s.hasOwnerToken) {
194
+ try {
195
+ const p = await owner("GET", "/owner/wake/preview");
196
+ s.ownerApi = { ok: true };
197
+ s.wake = { preset: p.preset, configured: !!p.configured, hasKey: !!p.hasKey, hasHmacSecret: !!p.hasHmacSecret,
198
+ hasAccessServiceToken: !!p.hasAccessServiceToken, urlFingerprint: p.fingerprints?.url || null };
199
+ } catch (e) { s.ownerApi = { ok: false, error: e.message }; }
200
+ if (s.ownerApi.ok) {
201
+ try {
202
+ const pr = await owner("GET", "/owner/pairing");
203
+ if (pr && typeof pr === "object" && ["human", "agent", "off"].includes(pr.mode))
204
+ s.pairing = { mode: pr.mode, passwordSet: !!pr.passwordSet, passwordSetAt: pr.passwordSetAt || null, passwordSetVia: pr.passwordSetVia || null,
205
+ setupLinkExpiresAt: pr.setupLinkExpiresAt || null, pending: (pr.pending || []).length };
206
+ } catch { /* older Worker (no pairing yet): leave unknown */ }
207
+ if (s.upstream) {
208
+ try { s.facade = await owner("GET", "/owner/facade"); }
209
+ catch (e) { s.facade = { mode: "unknown", error: e.message }; }
210
+ if (s.facade && s.facade.mode === "proxy") Object.assign(s, await upstreamCheck(s.baseUrl));
211
+ }
212
+ }
213
+ }
214
+ const dir = D.workerDir({});
215
+ const haveCf = fs.existsSync(dir);
216
+ const host = C.get("A2A_TUNNEL_HOSTNAME");
217
+ if (host || C.get("A2A_TUNNEL_ID")) {
218
+ const url = T.wakeUrl(host || "?", C.get("A2A_TUNNEL_PATH") || "/");
219
+ s.tunnel = { hostname: host, wakeUrl: url, origin: C.get("A2A_TUNNEL_ORIGIN"), complete: T.tunnelComplete(), tokenFileOk: T.tokenFileOk(),
220
+ tokenFile: C.get("A2A_TUNNEL_TOKEN_FILE") || "<config dir>/tunnel-token", workerUrlMatches: s.wake?.urlFingerprint === fingerprint(url), connections: null };
221
+ if (haveCf && C.get("A2A_TUNNEL_ID")) {
222
+ try {
223
+ const t = T.cfCall(dir, ["tunnels", "get", C.get("A2A_TUNNEL_ID")], { allowNotFound: true });
224
+ if (!t) s.tunnel.complete = false;
225
+ else { s.tunnel.state = t.status; s.tunnel.connections = (t.connections || []).length; }
226
+ } catch (e) { s.tunnel.error = e.message; }
227
+ }
228
+ } else if (s.wake && !s.wake.configured && haveCf) {
229
+ try { s.zones = T.listZones(dir).filter((z) => !z.status || z.status === "active").map((z) => z.name); } catch { /* not logged in, offline: leave unknown */ }
230
+ }
231
+ return s;
232
+ }
233
+
234
+ const VERIFY_METHOD = "a2a-exposed/verify-unknown-method";
235
+
236
+ /** The two upstream probes, neither creating anything: (1) public: an unauthenticated JSON-RPC call to the façade, which
237
+ * must answer 401 before looking at the method; (2) owner-authenticated: the Worker sends a JSON-RPC call with an
238
+ * unknown method to the upstream with its stored secrets and classifies the answer (POST /owner/facade/verify). */
239
+ export async function upstreamCheck(base) {
240
+ const out = { facadePeerAuth: null, upstreamVerify: null, upstreamVerifyError: "" };
241
+ try {
242
+ const r = await httpJson(base + "/", { method: "POST", body: { jsonrpc: "2.0", id: "a2a-exposed-verify", method: VERIFY_METHOD, params: {} }, headers: { "a2a-version": "1.0" }, timeout: 15000 });
243
+ out.facadePeerAuth = { status: r.status };
244
+ } catch (e) { out.facadePeerAuth = { status: null, error: e.message }; }
245
+ try {
246
+ const r = await ownerTry("POST", "/owner/facade/verify", {});
247
+ if (r.status === 200 && r.data && typeof r.data === "object" && r.data.reason) out.upstreamVerify = r.data;
248
+ else out.upstreamVerifyError = describeHttp(r.status, r.data);
249
+ } catch (e) { out.upstreamVerifyError = e.message; }
250
+ return out;
251
+ }
252
+
253
+ /** `upstream verify`: the façade's whole path, layer by layer; exit 1 unless the upstream app answered. */
254
+ export async function upstreamVerify(o) {
255
+ if (!C.get("A2A_UPSTREAM_URL")) die(`not a façade: ${C.CONFIG_FILE} has no A2A_UPSTREAM_URL (proxy mode: \`${CLI} deploy --upstream <tunnel URL>\`). For an inbox, check wakes with \`${CLI} wake test\``);
256
+ const base = baseUrl();
257
+ if (!base || !C.get("A2A_OWNER_TOKEN")) die(`A2A_BASE_URL / A2A_OWNER_TOKEN missing (run \`${CLI} init\` or edit ${C.CONFIG_FILE})`);
258
+ const r = await upstreamCheck(base);
259
+ const v = r.upstreamVerify;
260
+ if (o.json) console.log(JSON.stringify({ ...r, ok: !!(v && v.ok) }, null, 2));
261
+ else {
262
+ const rows = [["upstream", C.get("A2A_UPSTREAM_URL")], ...verifyLayers(v, r.facadePeerAuth)];
263
+ if (!v) rows.push(["check", `FAILED: ${r.upstreamVerifyError}${/HTTP 404/.test(r.upstreamVerifyError) ? ` (this Worker predates \`upstream verify\`: run \`${CLI} deploy\`)` : ""}`]);
264
+ else rows.push(["result", v.ok ? "OK: the façade reaches the agent with its stored credentials" : `${v.reason}: ${UPSTREAM_FIX[v.reason] ? UPSTREAM_FIX[v.reason](v.detail || v.reason) : v.detail}`]);
265
+ for (const [k, val] of rows) console.log(`${(k + ":").padEnd(18)} ${val}`);
266
+ }
267
+ if (!(v && v.ok) || (r.facadePeerAuth && r.facadePeerAuth.status !== null && r.facadePeerAuth.status !== 401)) process.exitCode = 1;
268
+ }
269
+
270
+ export async function status(o) {
271
+ const s = await collect();
272
+ const next = nextStep(s);
273
+ if (o.json) console.log(JSON.stringify({ ...s, nextStep: next.text, also: next.also, ok: next.ok }, null, 2));
274
+ else {
275
+ const rows = [
276
+ ["config", s.configFile],
277
+ ["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"],
278
+ ["base URL", s.baseUrl ? `${s.baseUrl} (${s.baseUrlEnv ? `from the exported A2A_BASE_URL; ${s.configFile} has ${s.baseUrlSaved || "none"}` : s.urlKind})` : "(not saved)"],
279
+ ];
280
+ if (s.deployed && s.baseUrl) {
281
+ rows.push(["agent card", !s.card.ok ? `FAILED: ${s.card.error}` : s.card.urlProblem ? `WRONG URL: "${s.card.name}", but ${s.card.urlProblem}`
282
+ : `OK: "${s.card.name}" (A2A ${s.card.versions.join(", ") || "?"})`]);
283
+ rows.push(["owner API", s.ownerApi.ok ? "OK" : `FAILED: ${s.ownerApi.error}`]);
284
+ if (s.upstream) {
285
+ const f = s.facade || {};
286
+ rows.push(["upstream", `${s.upstream} (proxy mode)${f.mode === "proxy" ? `; card ${f.upstreamCard}${f.upstreamVersions && f.upstreamVersions.length ? ` (A2A ${f.upstreamVersions.join(", ")})` : ""}; public card ${f.publicCardLeaks && f.publicCardLeaks.length ? "LEAKS a private URL" : "clean"}` : f.error ? `; ${f.error}` : "; the Worker is not in proxy mode"}`]);
287
+ if (f.mode === "proxy") {
288
+ const ua = f.upstreamAuth || {};
289
+ rows.push(["upstream Access", f.hasUpstreamAccessServiceToken ? `credential configured (UPSTREAM_ACCESS_CLIENT_ID fingerprint ${f.fingerprints?.upstreamAccessClientId || "?"})` : "NONE (UPSTREAM_ACCESS_CLIENT_ID / _SECRET not on the Worker)"]);
290
+ rows.push(["upstream bearer", f.hasUpstreamToken ? `bearer configured (UPSTREAM_TOKEN fingerprint ${f.fingerprints?.upstreamToken || "?"})`
291
+ : ua.bearer === true ? `NONE, but the upstream card asks for one (${(ua.schemes || []).join(", ")})${s.upstreamNoToken ? "; --no-upstream-token" : ""}`
292
+ : ua.bearer === false ? "none (the upstream card declares no bearer auth)" : `none${s.upstreamNoToken ? " (--no-upstream-token)" : ""}`]);
293
+ const v = s.upstreamVerify;
294
+ rows.push(["upstream check", !v ? `not run (${s.upstreamVerifyError || "?"})` : v.ok ? `OK: ${verifyLayers(v).map(([k, x]) => `${k} ${x.replace(/ \(.*$/, "")}`).join("; ")}` : `FAILED (${v.reason}): ${v.detail}`]);
295
+ if (s.facadePeerAuth) rows.push(["peer auth", verifyLayers(null, s.facadePeerAuth)[0][1]]);
296
+ if (f.lastFailure && f.lastFailure.reason) rows.push(["last failure", `${f.lastFailure.reason}${f.lastFailure.status ? ` (HTTP ${f.lastFailure.status})` : ""} on ${f.lastFailure.method || "?"} from peer ${f.lastFailure.peer || "?"} at ${f.lastFailure.at}`]);
297
+ }
298
+ }
299
+ rows.push(["wake", wakeLine(s)]);
300
+ const t = s.tunnel;
301
+ 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"}` +
302
+ (t.connections != null ? `; ${t.state || "?"}, ${t.connections} connector connection(s)` : t.error ? `; state unknown (${t.error})` : "")]);
303
+ if (s.zones) rows.push(["zones", s.zones.length ? s.zones.join(", ") : "none on this account"]);
304
+ const pg = s.pairing;
305
+ if (pg) rows.push(["pairing", pg.mode === "off" ? "off (token issue only)"
306
+ : `${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` : ""}`]);
307
+ }
308
+ rows.push(["next step", next.text]);
309
+ for (const a of next.also || []) rows.push(["also", a]);
310
+ const w = Math.max(12, ...rows.map(([k]) => k.length + 2));
311
+ for (const [k, v] of rows) console.log(`${(k + ":").padEnd(w)} ${v}`);
312
+ }
313
+ if (!next.ok) process.exitCode = 1;
314
+ }