a2a-exposed 0.8.26 → 0.8.28

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 CHANGED
@@ -1,23 +1,175 @@
1
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
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
+ import dns from "node:dns";
4
5
  import fs from "node:fs";
6
+ import { isIP } from "node:net";
5
7
  import path from "node:path";
8
+ import { promisify } from "node:util";
6
9
  import * as C from "./config.mjs";
7
- import { CLI, cardUrlProblem, cfErrorCode, describeHttp, die, fingerprint, httpJson } from "./a2a.mjs";
10
+ import { CLI, cardUrlProblem, cfErrorCode, describeHttp, die, fingerprint, httpJson, isPrivateHost } from "./a2a.mjs";
8
11
  import { baseUrl, owner, ownerTry } from "./commands.mjs";
9
12
  import * as D from "./deploy.mjs";
13
+ import { buildLayers, layerValue, usablePin } from "./layers.mjs";
10
14
  import * as T from "./tunnel.mjs";
11
15
  import { cacheFile, readCache, newer } from "./update.mjs";
12
16
 
17
+ const DOH_URL = "https://cloudflare-dns.com/dns-query";
18
+ const LOOKUP_MS = 5000;
19
+
20
+ /** ENOTFOUND / EAI_AGAIN, including the text httpJson puts on a failed request. */
21
+ export function isResolverError(e) {
22
+ const code = e?.code || e?.cause?.code || "";
23
+ if (code === "ENOTFOUND" || code === "EAI_AGAIN") return true;
24
+ return /\b(ENOTFOUND|EAI_AGAIN)\b/.test(String(e?.message || ""));
25
+ }
26
+
27
+ /** First address from the system resolver, or EAI_AGAIN when it does not answer in time. */
28
+ export async function defaultLookup(host) {
29
+ let timer;
30
+ const timeout = new Promise((_, reject) => {
31
+ timer = setTimeout(() => {
32
+ const err = new Error(`DNS lookup timed out for ${host}`);
33
+ err.code = "EAI_AGAIN";
34
+ reject(err);
35
+ }, LOOKUP_MS);
36
+ });
37
+ try {
38
+ const rows = await Promise.race([dns.promises.lookup(host, { all: true }), timeout]);
39
+ const first = Array.isArray(rows) ? rows[0] : null;
40
+ const address = typeof first === "string" ? first : first?.address;
41
+ if (!address) {
42
+ const err = new Error(`ENOTFOUND ${host}`);
43
+ err.code = "ENOTFOUND";
44
+ throw err;
45
+ }
46
+ return address;
47
+ } finally { clearTimeout(timer); }
48
+ }
49
+
50
+ /** Connect `host` to `ip` for the duration of `fn` (TLS still checks the hostname). Other names use the previous resolver. */
51
+ export async function withHostPin(host, ip, fn) {
52
+ const prev = dns.lookup;
53
+ const prevPromises = dns.promises.lookup;
54
+ dns.lookup = (hostname, options, callback) => {
55
+ if (typeof options === "function") { callback = options; options = {}; }
56
+ else if (typeof options === "number") options = { family: options };
57
+ else options = options || {};
58
+ if (hostname === host) {
59
+ if (options.all) return callback(null, [{ address: ip, family: 4 }]);
60
+ return callback(null, ip, 4);
61
+ }
62
+ return prev(hostname, options, callback);
63
+ };
64
+ dns.promises.lookup = promisify(dns.lookup);
65
+ try { return await fn(); }
66
+ finally {
67
+ dns.lookup = prev;
68
+ dns.promises.lookup = prevPromises;
69
+ }
70
+ }
71
+
72
+ /** DNS-over-HTTPS A (or the requested type) lookup. `application/dns-json`. No redirects.
73
+ * When the system resolver cannot resolve the DoH host, retry once pinned to 1.1.1.1. */
74
+ export async function queryDoh(name, type = "A") {
75
+ const url = `${DOH_URL}?name=${encodeURIComponent(name)}&type=${encodeURIComponent(type)}`;
76
+ const init = {
77
+ method: "GET", redirect: "manual",
78
+ headers: { accept: "application/dns-json", "user-agent": "a2a-exposed-cli" },
79
+ signal: AbortSignal.timeout(5000),
80
+ };
81
+ let res;
82
+ try { res = await fetch(url, init); }
83
+ catch (e) {
84
+ if (!isResolverError(e)) throw e;
85
+ res = await withHostPin("cloudflare-dns.com", "1.1.1.1", () => fetch(url, init));
86
+ }
87
+ if (!res || res.status !== 200) {
88
+ const err = new Error(`HTTP ${res ? res.status : 0}`);
89
+ err.code = `HTTP_${res ? res.status : 0}`;
90
+ throw err;
91
+ }
92
+ const data = await res.json();
93
+ const want = type === "AAAA" ? 28 : 1;
94
+ const answer = Array.isArray(data?.Answer) ? data.Answer : [];
95
+ return answer.filter((a) => a && Number(a.type) === want && typeof a.data === "string").map((a) => a.data);
96
+ }
97
+
98
+ function firstAddress(raw) {
99
+ if (typeof raw === "string") return raw;
100
+ if (Array.isArray(raw)) {
101
+ const first = raw[0];
102
+ return typeof first === "string" ? first : first?.address || "";
103
+ }
104
+ return raw?.address || "";
105
+ }
106
+
107
+ /** System lookup, then DNS-over-HTTPS on ENOTFOUND or EAI_AGAIN. `lookup` and `doh` are injectable. */
108
+ export async function resolveWorkerDns(host, { lookup = defaultLookup, doh = queryDoh } = {}) {
109
+ if (!host) return { kind: "skip" };
110
+ if (isIP(host)) return { kind: "ip", ip: host };
111
+ try {
112
+ const ip = firstAddress(await lookup(host));
113
+ return { kind: "system", ip };
114
+ } catch (e) {
115
+ if (!isResolverError(e)) return { kind: "fail", error: e?.code || e?.message || "lookup failed" };
116
+ let answers;
117
+ try { answers = await doh(host, "A"); }
118
+ catch (err) { return { kind: "fail", error: err?.code || err?.message || "DNS-over-HTTPS failed" }; }
119
+ const ips = (Array.isArray(answers) ? answers : []).map(String).filter((a) => isIP(a) === 4);
120
+ if (!ips.length) return { kind: "no-record" };
121
+ return { kind: "public-only", ip: ips[0] };
122
+ }
123
+ }
124
+
125
+ /** Public DNS for the wake hostname (DNS-over-HTTPS only). */
126
+ export async function resolveWakeDns(host, { doh = queryDoh } = {}) {
127
+ if (!host || isIP(host) || isPrivateHost(host)) return { kind: "skip" };
128
+ try {
129
+ const ips = (await doh(host, "A") || []).map(String).filter((a) => isIP(a) === 4);
130
+ if (!ips.length) return { kind: "no-record" };
131
+ return { kind: "pass", ip: ips[0] };
132
+ } catch (e) {
133
+ return { kind: "fail", error: e?.code || e?.message || "DNS-over-HTTPS failed" };
134
+ }
135
+ }
136
+
137
+ /** GET probe. Request headers are sent and never returned. The result keeps the status, redirect
138
+ * location, Cloudflare error code, Access response-header names (never their values), the
139
+ * WWW-Authenticate challenge, and a body slice, so a 401/403 can be attributed. */
140
+ async function probeGet(url, headers) {
141
+ try {
142
+ const r = await fetch(url, {
143
+ method: "GET", redirect: "manual",
144
+ headers: { "user-agent": "a2a-exposed-cli", ...(headers || {}) },
145
+ signal: AbortSignal.timeout(15000),
146
+ });
147
+ const body = (await r.text()).slice(0, 8192);
148
+ const cf = T.cloudflareErrorCode(body);
149
+ const accessHeaders = [];
150
+ r.headers.forEach((_value, name) => {
151
+ if (name.toLowerCase().startsWith("cf-access-")) accessHeaders.push(name.toLowerCase());
152
+ });
153
+ const wwwAuthenticate = r.headers.get("www-authenticate") || "";
154
+ return {
155
+ status: r.status, location: r.headers.get("location") || "", ...(cf ? { cf } : {}),
156
+ ...(accessHeaders.length ? { accessHeaders } : {}),
157
+ ...(wwwAuthenticate ? { wwwAuthenticate: wwwAuthenticate.slice(0, 500) } : {}),
158
+ ...(body ? { body } : {}),
159
+ };
160
+ } catch (e) {
161
+ return { error: e?.cause?.code || e?.cause?.message || e?.message || "request failed" };
162
+ }
163
+ }
164
+
13
165
  /** Public health probe. Only a valid health answer establishes missing version reporting. */
14
166
  export async function checkHealth(base) {
15
167
  try {
16
168
  const { status, data } = await httpJson(`${base}/health`, { timeout: 15000 });
17
169
  if (status === 200 && data?.ok === true)
18
- return { ok: true, version: typeof data.version === "string" && data.version ? data.version : null };
19
- } catch { /* unreachable: version stays unknown */ }
20
- return { ok: false, version: null };
170
+ return { ok: true, version: typeof data.version === "string" && data.version ? data.version : null, httpStatus: status };
171
+ return { ok: false, version: null, httpStatus: status };
172
+ } catch (e) { /* unreachable: version stays unknown */ return { ok: false, version: null, httpStatus: null, error: e.message }; }
21
173
  }
22
174
 
23
175
  /** Presets whose webhook usually listens only locally: wakes need the tunnel (or another reverse proxy). */
@@ -30,11 +182,11 @@ export async function checkCard(base) {
30
182
  const { status, data } = await httpJson(`${base}/.well-known/agent-card.json`, { timeout: 15000 });
31
183
  if (status !== 200 || !data || typeof data !== "object" || !data.name) {
32
184
  const cf = cfErrorCode(typeof data === "string" ? data : "");
33
- return { ok: false, error: status === 200 ? "HTTP 200 (no agent card)" : describeHttp(status, data), ...(cf ? { cfError: cf } : {}) };
185
+ return { ok: false, httpStatus: status, error: status === 200 ? "HTTP 200 (no agent card)" : describeHttp(status, data), ...(cf ? { cfError: cf } : {}) };
34
186
  }
35
- return { ok: true, name: data.name, versions: (data.supportedInterfaces || []).map((i) => i.protocolVersion).filter(Boolean),
187
+ return { ok: true, httpStatus: status, name: data.name, versions: (data.supportedInterfaces || []).map((i) => i.protocolVersion).filter(Boolean),
36
188
  urls: [...new Set((data.supportedInterfaces || []).map((i) => i && i.url).filter(Boolean))], urlProblem: cardUrlProblem(data, base) };
37
- } catch (e) { return { ok: false, error: e.message }; }
189
+ } catch (e) { return { ok: false, httpStatus: null, error: e.message }; }
38
190
  }
39
191
 
40
192
  /** One line describing how wakes reach the agent. */
@@ -232,8 +384,65 @@ function nextStepCore(s) {
232
384
  [`peers connect with \`${CLI} connect ${s.baseUrl}\` (your human approves each one); \`${CLI} token issue <peer>\` is the manual fallback`]);
233
385
  }
234
386
 
235
- /** Collect the snapshot. Each probe tolerates failure; nothing is created or changed. */
236
- export async function collect() {
387
+ function hostOf(url) {
388
+ try { return new URL(url).hostname; } catch { return ""; }
389
+ }
390
+
391
+ /** Facts for the eight layers. Tokens and client secrets stay out of this object. */
392
+ function layerFacts(s, extra) {
393
+ const t = s.tunnel;
394
+ return {
395
+ deployed: s.deployed, baseUrl: s.baseUrl || "", host: extra.host || "", platform: process.platform,
396
+ workerDns: extra.workerDns, card: s.card, health: s.health, hasOwnerToken: s.hasOwnerToken, ownerApi: s.ownerApi,
397
+ wakeHost: extra.wakeHost || "", wakeDns: extra.wakeDns, bare: extra.bare, authed: extra.authed,
398
+ serviceToken: extra.serviceToken, localServiceToken: extra.localServiceToken, pairing: s.pairing,
399
+ tunnel: t ? {
400
+ complete: t.complete, tokenFileOk: t.tokenFileOk, tokenFile: t.tokenFile, connections: t.connections,
401
+ grace: connectorConnecting(t), origin: t.origin, down1033: !!extra.down1033,
402
+ } : null,
403
+ };
404
+ }
405
+
406
+ /** Wake DNS, the bare Access probe, and the service-token probe. GET only. The Access token is sent
407
+ * and not stored. The webhook's bearer and HMAC are not sent. */
408
+ async function wakeProbes(s, net) {
409
+ const wakeHost = s.tunnel?.hostname || "";
410
+ const empty = { wakeHost, wakeDns: { kind: "skip" }, bare: null, authed: null, down1033: false };
411
+ if (!wakeHost) return empty;
412
+ const wakeDns = await resolveWakeDns(wakeHost, net);
413
+ if (wakeDns.kind !== "pass" || !s.tunnel?.wakeUrl) return { ...empty, wakeDns };
414
+ const localServiceToken = !!(C.get("A2A_TUNNEL_ACCESS_CLIENT_ID") && C.get("A2A_TUNNEL_ACCESS_CLIENT_SECRET"));
415
+ const run = async () => {
416
+ const bare = await probeGet(s.tunnel.wakeUrl);
417
+ let authed = null;
418
+ if (localServiceToken) {
419
+ authed = await probeGet(s.tunnel.wakeUrl, {
420
+ "CF-Access-Client-Id": C.get("A2A_TUNNEL_ACCESS_CLIENT_ID"),
421
+ "CF-Access-Client-Secret": C.get("A2A_TUNNEL_ACCESS_CLIENT_SECRET"),
422
+ });
423
+ }
424
+ return { bare, authed };
425
+ };
426
+ let probed;
427
+ try {
428
+ probed = wakeDns.ip && usablePin(wakeDns.ip) ? await withHostPin(wakeHost, wakeDns.ip, run) : await run();
429
+ } catch (e) {
430
+ probed = { bare: { error: e?.message || "request failed" }, authed: null };
431
+ }
432
+ const down1033 = [probed.bare, probed.authed].some((p) => p && p.cf === "1033");
433
+ return { wakeHost, wakeDns, bare: probed.bare, authed: probed.authed, down1033 };
434
+ }
435
+
436
+ function finishLayers(s, extra) {
437
+ const localServiceToken = !!(C.get("A2A_TUNNEL_ACCESS_CLIENT_ID") && C.get("A2A_TUNNEL_ACCESS_CLIENT_SECRET"));
438
+ const serviceToken = localServiceToken || s.wake?.hasAccessServiceToken ? "configured" : "not-configured";
439
+ s.layers = buildLayers(layerFacts(s, { ...extra, localServiceToken, serviceToken }));
440
+ return s;
441
+ }
442
+
443
+ /** Collect the snapshot. Each probe tolerates failure; nothing is created or changed.
444
+ * `net.lookup` and `net.doh` replace the system resolver and DNS-over-HTTPS in tests. */
445
+ export async function collect(net = {}) {
237
446
  const s = {
238
447
  configFile: C.CONFIG_FILE,
239
448
  deployed: !!(C.get("A2A_D1_ID") && C.get("A2A_WORKER_NAME")),
@@ -248,61 +457,68 @@ export async function collect() {
248
457
  };
249
458
  const envBase = (process.env.A2A_BASE_URL || "").replace(/\/$/, "");
250
459
  if (envBase && envBase !== s.baseUrlSaved) s.baseUrlEnv = envBase;
251
- if (!s.deployed || !s.baseUrl) return s;
252
- s.card = await checkCard(s.baseUrl);
253
- s.health = await checkHealth(s.baseUrl);
254
- s.workerVersion = s.health.version;
255
- if (s.hasOwnerToken) {
256
- try {
257
- const p = await owner("GET", "/owner/wake/preview");
258
- s.ownerApi = { ok: true };
259
- s.wake = { preset: p.preset, configured: !!p.configured, hasKey: !!p.hasKey, hasHmacSecret: !!p.hasHmacSecret,
260
- hasAccessServiceToken: !!p.hasAccessServiceToken, urlFingerprint: p.fingerprints?.url || null };
261
- } catch (e) { s.ownerApi = { ok: false, error: e.message }; }
262
- if (s.ownerApi.ok) {
460
+ if (!s.deployed || !s.baseUrl) return finishLayers(s, { host: "", workerDns: { kind: "skip" }, wakeHost: "", wakeDns: { kind: "skip" }, bare: null, authed: null });
461
+ const host = hostOf(s.baseUrl);
462
+ const workerDns = await resolveWorkerDns(host, net);
463
+ const remote = async () => {
464
+ s.card = await checkCard(s.baseUrl);
465
+ s.health = await checkHealth(s.baseUrl);
466
+ s.workerVersion = s.health.version;
467
+ if (s.hasOwnerToken) {
263
468
  try {
264
- const pr = await owner("GET", "/owner/pairing");
265
- if (pr && typeof pr === "object" && ["human", "agent", "off"].includes(pr.mode))
266
- s.pairing = { mode: pr.mode, passwordSet: !!pr.passwordSet, passwordSetAt: pr.passwordSetAt || null, passwordSetVia: pr.passwordSetVia || null,
267
- setupLinkExpiresAt: pr.setupLinkExpiresAt || null, pending: (pr.pending || []).length,
268
- ...(typeof pr.oidcIssuer === "string" && pr.oidcIssuer ? { oidcIssuer: pr.oidcIssuer } : {}),
269
- ...(typeof pr.manageUrl === "string" && pr.manageUrl ? { manageUrl: pr.manageUrl } : {}) };
270
- } catch { /* older Worker (no pairing yet): leave unknown */ }
271
- try {
272
- const ad = await ownerTry("GET", "/owner/address");
273
- if (ad.status === 200 && ad.data && typeof ad.data === "object") s.addresses = ad.data;
274
- } catch { /* older Worker: no address lifecycle */ }
275
- if (s.upstream) {
276
- try { s.facade = await owner("GET", "/owner/facade"); }
277
- catch (e) { s.facade = { mode: "unknown", error: e.message }; }
278
- if (s.facade && s.facade.mode === "proxy") Object.assign(s, await upstreamCheck(s.baseUrl));
469
+ const p = await owner("GET", "/owner/wake/preview");
470
+ s.ownerApi = { ok: true };
471
+ s.wake = { preset: p.preset, configured: !!p.configured, hasKey: !!p.hasKey, hasHmacSecret: !!p.hasHmacSecret,
472
+ hasAccessServiceToken: !!p.hasAccessServiceToken, urlFingerprint: p.fingerprints?.url || null };
473
+ } catch (e) { s.ownerApi = { ok: false, error: e.message }; }
474
+ if (s.ownerApi.ok) {
475
+ try {
476
+ const pr = await owner("GET", "/owner/pairing");
477
+ if (pr && typeof pr === "object" && ["human", "agent", "off"].includes(pr.mode))
478
+ s.pairing = { mode: pr.mode, passwordSet: !!pr.passwordSet, passwordSetAt: pr.passwordSetAt || null, passwordSetVia: pr.passwordSetVia || null,
479
+ setupLinkExpiresAt: pr.setupLinkExpiresAt || null, pending: (pr.pending || []).length,
480
+ ...(typeof pr.oidcIssuer === "string" && pr.oidcIssuer ? { oidcIssuer: pr.oidcIssuer } : {}),
481
+ ...(typeof pr.manageUrl === "string" && pr.manageUrl ? { manageUrl: pr.manageUrl } : {}) };
482
+ } catch { /* older Worker (no pairing yet): leave unknown */ }
483
+ try {
484
+ const ad = await ownerTry("GET", "/owner/address");
485
+ if (ad.status === 200 && ad.data && typeof ad.data === "object") s.addresses = ad.data;
486
+ } catch { /* older Worker: no address lifecycle */ }
487
+ if (s.upstream) {
488
+ try { s.facade = await owner("GET", "/owner/facade"); }
489
+ catch (e) { s.facade = { mode: "unknown", error: e.message }; }
490
+ if (s.facade && s.facade.mode === "proxy") Object.assign(s, await upstreamCheck(s.baseUrl));
491
+ }
492
+ try {
493
+ const m = await ownerTry("GET", "/owner/mcp");
494
+ if (m.status === 200 && m.data && typeof m.data === "object" && typeof m.data.enabled === "boolean")
495
+ s.mcp = m.data.enabled ? "on" : "off";
496
+ } catch { /* probe failed: leave mcp unknown so status omits the add line */ }
279
497
  }
280
- try {
281
- const m = await ownerTry("GET", "/owner/mcp");
282
- if (m.status === 200 && m.data && typeof m.data === "object" && typeof m.data.enabled === "boolean")
283
- s.mcp = m.data.enabled ? "on" : "off";
284
- } catch { /* probe failed: leave mcp unknown so status omits the add line */ }
285
498
  }
286
- }
287
- const dir = D.workerDir({});
288
- const haveCf = fs.existsSync(dir);
289
- const host = C.get("A2A_TUNNEL_HOSTNAME");
290
- if (host || C.get("A2A_TUNNEL_ID")) {
291
- const url = T.wakeUrl(host || "?", C.get("A2A_TUNNEL_PATH") || "/");
292
- s.tunnel = { hostname: host, wakeUrl: url, origin: C.get("A2A_TUNNEL_ORIGIN"), complete: T.tunnelComplete(), tokenFileOk: T.tokenFileOk(),
293
- tokenFile: C.get("A2A_TUNNEL_TOKEN_FILE") || "<config dir>/tunnel-token", workerUrlMatches: s.wake?.urlFingerprint === fingerprint(url),
294
- secretsAt: Number(C.get("A2A_TUNNEL_SECRETS_AT")) || 0, connections: null };
295
- if (haveCf && C.get("A2A_TUNNEL_ID")) {
296
- try {
297
- const t = T.cfCall(dir, ["tunnels", "get", C.get("A2A_TUNNEL_ID")], { allowNotFound: true });
298
- if (!t) s.tunnel.complete = false;
299
- else { s.tunnel.state = t.status; s.tunnel.connections = (t.connections || []).length; }
300
- } catch (e) { s.tunnel.error = e.message; }
499
+ const dir = D.workerDir({});
500
+ const haveCf = fs.existsSync(dir);
501
+ const tunnelHost = C.get("A2A_TUNNEL_HOSTNAME");
502
+ if (tunnelHost || C.get("A2A_TUNNEL_ID")) {
503
+ const url = T.wakeUrl(tunnelHost || "?", C.get("A2A_TUNNEL_PATH") || "/");
504
+ s.tunnel = { hostname: tunnelHost, wakeUrl: url, origin: C.get("A2A_TUNNEL_ORIGIN"), complete: T.tunnelComplete(), tokenFileOk: T.tokenFileOk(),
505
+ tokenFile: C.get("A2A_TUNNEL_TOKEN_FILE") || "<config dir>/tunnel-token", workerUrlMatches: s.wake?.urlFingerprint === fingerprint(url),
506
+ secretsAt: Number(C.get("A2A_TUNNEL_SECRETS_AT")) || 0, connections: null };
507
+ if (haveCf && C.get("A2A_TUNNEL_ID")) {
508
+ try {
509
+ const t = T.cfCall(dir, ["tunnels", "get", C.get("A2A_TUNNEL_ID")], { allowNotFound: true });
510
+ if (!t) s.tunnel.complete = false;
511
+ else { s.tunnel.state = t.status; s.tunnel.connections = (t.connections || []).length; }
512
+ } catch (e) { s.tunnel.error = e.message; }
513
+ }
514
+ } else if (s.wake && !s.wake.configured && haveCf) {
515
+ try { s.zones = T.listZones(dir).filter((z) => !z.status || z.status === "active").map((z) => z.name); } catch { /* not logged in, offline: leave unknown */ }
301
516
  }
302
- } else if (s.wake && !s.wake.configured && haveCf) {
303
- try { s.zones = T.listZones(dir).filter((z) => !z.status || z.status === "active").map((z) => z.name); } catch { /* not logged in, offline: leave unknown */ }
304
- }
305
- return s;
517
+ };
518
+ if (workerDns.kind === "public-only" && usablePin(workerDns.ip)) await withHostPin(host, workerDns.ip, remote);
519
+ else await remote();
520
+ const wake = await wakeProbes(s, net);
521
+ return finishLayers(s, { host, workerDns, ...wake });
306
522
  }
307
523
 
308
524
  const VERIFY_METHOD = "a2a-exposed/verify-unknown-method";
@@ -351,8 +567,10 @@ export async function status(o) {
351
567
  const s = await collect();
352
568
  const next = nextStep(s);
353
569
  const mcpAdd = mcpAddCommand(s);
570
+ const layers = Array.isArray(s.layers) ? s.layers : [];
571
+ const ok = next.ok && !layers.some((l) => l.result === "FAIL");
354
572
  if (o.json) {
355
- const out = { ...s, mcpAdd, nextStep: next.text, also: next.also, ok: next.ok };
573
+ const out = { ...s, mcpAdd, nextStep: next.text, also: next.also, ok, layers };
356
574
  if (out.tunnel && out.tunnel.state != null) out.tunnel = { ...out.tunnel, state: tunnelStateLabel(out.tunnel.state) };
357
575
  console.log(JSON.stringify(out, null, 2));
358
576
  }
@@ -402,10 +620,11 @@ export async function status(o) {
402
620
  : `${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.oidcIssuer ? `; OpenID Connect (${pg.oidcIssuer})` : ""}${pg.manageUrl ? `; dashboard ${pg.manageUrl}` : ""}${pg.pending ? `; ${pg.pending} pending request(s): ${CLI} pair list` : ""}`]);
403
621
  }
404
622
  if (mcpAdd) rows.push(["mcp", mcpAdd]);
623
+ for (const l of layers) rows.push([l.name, layerValue(l)]);
405
624
  rows.push(["next step", next.text]);
406
625
  for (const a of next.also || []) rows.push(["also", a]);
407
626
  const w = Math.max(12, ...rows.map(([k]) => k.length + 2));
408
627
  for (const [k, v] of rows) console.log(`${(k + ":").padEnd(w)} ${v}`);
409
628
  }
410
- if (!next.ok) process.exitCode = 1;
629
+ if (!ok) process.exitCode = 1;
411
630
  }
package/lib/tunnel.mjs CHANGED
@@ -14,6 +14,7 @@ import * as C from "./config.mjs";
14
14
  import { CLI, CliError, die, fingerprint, httpJson } from "./a2a.mjs";
15
15
  import { owner } from "./commands.mjs";
16
16
  import * as D from "./deploy.mjs";
17
+ import { writeTokenFile } from "./local-link.mjs";
17
18
 
18
19
  const step = (s) => console.error(`==> ${s}`);
19
20
 
@@ -366,8 +367,7 @@ function fetchTokenFile(dir, tunnelId) {
366
367
  const token = typeof tok === "string" ? tok.trim() : tok.raw || tok.token || tok.result || "";
367
368
  if (!token) die("could not read the tunnel token");
368
369
  const tokenFile = path.join(C.CONFIG_DIR, "tunnel-token");
369
- fs.writeFileSync(tokenFile, token + "\n", { mode: 0o600 });
370
- fs.chmodSync(tokenFile, 0o600);
370
+ writeTokenFile(tokenFile, token);
371
371
  C.saveConfig({ A2A_TUNNEL_TOKEN_FILE: tokenFile });
372
372
  return { tokenFile, token };
373
373
  }
@@ -1,6 +1,6 @@
1
1
  // workers.dev support: URL helpers, cf output parsing, and registering the account's workers.dev subdomain
2
2
  // through cf's own interactive prompt (cf has no non-interactive command for it).
3
- import { spawn } from "node:child_process";
3
+ import { spawnScript, stopScript } from "./script.mjs";
4
4
  import { CliError, die } from "./a2a.mjs";
5
5
 
6
6
  /** A valid DNS label: worker names and workers.dev subdomains must be one. */
@@ -65,12 +65,18 @@ export function deployWithRegistration(cfBin, args, name, { cwd, env, idleMs = 1
65
65
  const penv = { ...env, TERM: env?.TERM || "xterm-256color", COLUMNS: "160", LINES: "50" };
66
66
  delete penv.CI; // cf only prompts when interactive
67
67
  return new Promise((resolve, reject) => {
68
- const ch = spawn("script", sargs, { cwd, env: penv, stdio: ["pipe", "pipe", "pipe"] });
68
+ const ch = spawnScript(sargs, { cwd, env: penv });
69
69
  // cf's prompts, in order; each is answered once, and only from output that arrived after the previous
70
70
  // answer (prompt libraries re-render answered questions, which must not be answered twice)
71
71
  const steps = REGISTRATION_STEPS(name);
72
- let out = "", pending = "", stage = 0, failure = "";
73
- const stop = (why) => { failure ||= why; ch.kill("SIGTERM"); };
72
+ let out = "", pending = "", stage = 0, failure = "", escalation;
73
+ const stop = (why) => {
74
+ if (failure) return;
75
+ failure = why;
76
+ stopScript(ch);
77
+ escalation = setTimeout(() => stopScript(ch, "SIGKILL"), 1000);
78
+ escalation.unref();
79
+ };
74
80
  const arm = () => setTimeout(() => stop("cf stopped responding (unexpected prompt?)"), idleMs);
75
81
  let idle = arm();
76
82
  const onData = (d) => {
@@ -86,9 +92,10 @@ export function deployWithRegistration(cfBin, args, name, { cwd, env, idleMs = 1
86
92
  };
87
93
  ch.stdout.on("data", onData);
88
94
  ch.stderr.on("data", onData);
89
- ch.on("error", (e) => { clearTimeout(idle); reject(e); });
95
+ ch.on("error", (e) => { clearTimeout(idle); clearTimeout(escalation); reject(e); });
90
96
  ch.on("close", (code) => {
91
97
  clearTimeout(idle);
98
+ clearTimeout(escalation);
92
99
  ch.stdin.destroy();
93
100
  if (failure) return reject(new CliError(failure));
94
101
  resolve({ status: code ?? 1, out });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.8.26",
3
+ "version": "0.8.28",
4
4
  "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: a Cloudflare Worker, a wake webhook, or scheduled checks",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2445,7 +2445,7 @@ async function deviceAuthorization(req: Request, ctx: TenantContext, ectx: Execu
2445
2445
  if (!(await wakeBudgetOk(ctx))) return log("wake_skipped", { reason: "hourly cap", kind: "pairing_request" });
2446
2446
  const identityFingerprint = identity ? await A.fingerprint(identity) : null;
2447
2447
  await sendWake(ctx, ectx, { contextId: "pairing", taskId: "none", taskIds: [], from: clientName || clientId || "unknown agent", preview: "",
2448
- kind: "pairing_request", pairing: { userCode: shown, verificationUriComplete: complete, approval: mode, clientName, clientId, agentCardUrl: cardUrl,
2448
+ kind: "pairing_request", pairing: { passwordSet: !!(await approvalPassword(ctx)), passwordEnabled: O.approvalPolicy(ctx.gates.approvalOidc).password, userCode: shown, verificationUriComplete: complete, approval: mode, clientName, clientId, agentCardUrl: cardUrl,
2449
2449
  ...(cardUrl && P.cardIsPrivate(cardUrl) ? { agentCardPrivate: true } : {}),
2450
2450
  ...(identityFingerprint ? { identityFingerprint } : {}), expiresIn: P.EXPIRES_S,
2451
2451
  ...(manage ? { manageUrl: manage } : {}),
@@ -1,2 +1,2 @@
1
1
  /** Inbox build. Keep this equal to `version` in cli/package.json. */
2
- export const VERSION = "0.8.26";
2
+ export const VERSION = "0.8.28";
@@ -23,6 +23,8 @@ export type PairingInfo = {
23
23
  agentCardUrl: string;
24
24
  agentCardPrivate?: boolean; // the claimed card is on a private network (Tailnet, LAN): informational, not reachable
25
25
  expiresIn: number;
26
+ passwordSet?: boolean; // readiness only; never the password or its hash
27
+ passwordEnabled?: boolean; // effective approval policy, including incomplete-OIDC fallback
26
28
  /** Dashboard page for this agent. Present only when MANAGE_URL is an https URL. */
27
29
  manageUrl?: string;
28
30
  replacesLabel?: string; // re-pairing: the requester proved it holds this active token; approval replaces it
@@ -78,16 +80,18 @@ export function wakePayload(ev: WakeEvent, cli: string): Record<string, unknown>
78
80
  };
79
81
  }
80
82
 
81
- /** What the woken agent must do with a pairing request: ask its human, never approve on its own. */
83
+ /** What the woken agent must do with a pairing request: ask its owner, never approve on its own. */
82
84
  export function pairingInstructions(p: PairingInfo, cli: string): string {
85
+ const readiness = p.passwordSet === false && p.passwordEnabled !== false
86
+ ? ` Approval password: not set. To approve pairing on /device with a password, run: ${cli} pair set-password --web. Send the one-time setup link privately to your human; they set the password themselves. Never open the page or ask for the password.` : "";
83
87
  const dash = p.manageUrl ? ` The dashboard page for this request is ${p.manageUrl}.` : "";
84
88
  return p.approval === "human"
85
89
  ? `Ask your human; never approve on your own. Show them the code ${p.userCode} and give them this link: ${p.verificationUriComplete} . ` +
86
90
  `They approve there with their approval password, or with \`${cli} pair approve ${p.userCode}\` (the owner token), after checking the code with the other agent's owner. Deny with \`${cli} pair deny ${p.userCode}\`. Details: \`${cli} pair list\`.` +
87
- dash
91
+ dash + readiness
88
92
  : `Ask your human; never approve on your own. Show them the code ${p.userCode}, who is asking, and the link ${p.verificationUriComplete} . ` +
89
93
  `Only if they say yes, run \`${cli} pair approve ${p.userCode}\`; if they say no (or don't answer), \`${cli} pair deny ${p.userCode}\`.` +
90
- dash;
94
+ dash + readiness;
91
95
  }
92
96
 
93
97
  function hintFor(ev: WakeEvent, cli: string): string {
@@ -272,6 +272,8 @@ test("human approval: pending, slow_down, page approve with the password, single
272
272
  assert.equal(w.pairing.agentCardUrl, "https://barry.example.org/.well-known/agent-card.json");
273
273
  assert.equal("manageUrl" in w.pairing, false);
274
274
  assert.match(w.pairing.instructions, /never approve on your own[\s\S]*give them this link/);
275
+ assert.equal(w.pairing.passwordSet, false);
276
+ assert.match(w.pairing.instructions, /Approval password: not set[\s\S]*pair set-password --web/);
275
277
 
276
278
  let p = await s.poll(d.device_code);
277
279
  assert.deepEqual([p.status, p.data.error, p.data.error_description], [400, "authorization_pending", "waiting for the agent owner to approve"]);
@@ -971,3 +973,38 @@ test("approval page shows the visitor's clock, and an https MANAGE_URL is on the
971
973
  await bad.start(undefined, "203.0.113.82");
972
974
  assert.equal("manageUrl" in bad.wakes[0].body.pairing, false);
973
975
  });
976
+
977
+ test("pairing wake omits setup guidance once a password exists", async (t) => {
978
+ const s = setup(); t.after(s.restore);
979
+ await s.owner("PUT", "/owner/pairing/password", await passwordRecord());
980
+ assert.equal((await s.start()).status, 200);
981
+ const pairing = s.wakes[0].body.pairing;
982
+ assert.equal(pairing.passwordSet, true);
983
+ assert.doesNotMatch(pairing.instructions, /pair set-password|Approval password: not set/);
984
+ assert.doesNotMatch(JSON.stringify(s.wakes), /pbkdf2|correct horse|salt|hash/);
985
+ });
986
+
987
+ test("pairing readiness reaches text wakes without requester text on the trusted preset", async () => {
988
+ const ev: WakeEvent = { contextId: "pairing", taskId: "none", taskIds: [], from: "Untrusted", preview: "", kind: "pairing_request", publicUrl: BASE,
989
+ pairing: { userCode: "WDJB-2345", verificationUriComplete: `${BASE}/device?user_code=WDJB-2345`, approval: "human", clientName: "Untrusted", clientId: "untrusted", agentCardUrl: "", expiresIn: 3600, passwordSet: false } };
990
+ for (const preset of ["claude-code", "openclaw-wake", "openclaw-agent"]) {
991
+ const request = (await renderWake({ preset, url: "https://hook.example.net/wake", cliCommand: "a2a" }, ev))!;
992
+ const body = JSON.parse(request.body);
993
+ assert.match(body.text || body.message, /Approval password: not set[\s\S]*a2a pair set-password --web/);
994
+ if (preset === "openclaw-wake") assert.doesNotMatch(body.text, /Untrusted/);
995
+ }
996
+ });
997
+
998
+ for (const [name, config, enabled] of [
999
+ ["OIDC only", { APPROVAL_METHODS: "oidc", APPROVAL_OIDC_ISSUER: "https://idp.example", APPROVAL_OIDC_CLIENT_ID: "client-1", APPROVAL_OIDC_CLIENT_SECRET: "dummy-secret", APPROVAL_OIDC_ALLOWED_SUBJECTS: "owner-1" }, false],
1000
+ ["incomplete OIDC fallback", { APPROVAL_METHODS: "oidc", APPROVAL_OIDC_ISSUER: "https://idp.example", APPROVAL_OIDC_CLIENT_ID: "client-1" }, true],
1001
+ ["password and OIDC", { APPROVAL_METHODS: "password,oidc", APPROVAL_OIDC_ISSUER: "https://idp.example", APPROVAL_OIDC_CLIENT_ID: "client-1", APPROVAL_OIDC_CLIENT_SECRET: "dummy-secret", APPROVAL_OIDC_ALLOWED_SUBJECTS: "owner-1" }, true],
1002
+ ] as const) test(`pairing wake readiness respects ${name}`, async (t) => {
1003
+ const s = setup(config); t.after(s.restore);
1004
+ assert.equal((await s.start()).status, 200);
1005
+ const pairing = s.wakes[0].body.pairing;
1006
+ assert.equal(pairing.passwordSet, false);
1007
+ assert.equal(pairing.passwordEnabled, enabled);
1008
+ if (enabled) assert.match(pairing.instructions, /Approval password: not set[\s\S]*pair set-password --web/);
1009
+ else assert.doesNotMatch(pairing.instructions, /Approval password: not set|pair set-password --web/);
1010
+ });