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/config.mjs +8 -0
- package/lib/deploy.mjs +77 -10
- package/lib/feedback.mjs +2 -1
- package/lib/layers.mjs +205 -0
- package/lib/link-finish.mjs +178 -0
- package/lib/local-link.mjs +101 -0
- package/lib/main.mjs +34 -10
- package/lib/script.mjs +56 -0
- package/lib/status.mjs +281 -62
- package/lib/tunnel.mjs +2 -2
- package/lib/workersdev.mjs +12 -5
- package/package.json +1 -1
- package/worker/src/index.ts +1 -1
- package/worker/src/version.ts +1 -1
- package/worker/src/wake.ts +7 -3
- package/worker/test/pairing.test.ts +37 -0
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
catch
|
|
278
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
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
|
-
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
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
|
}
|
package/lib/workersdev.mjs
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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) => {
|
|
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
package/worker/src/index.ts
CHANGED
|
@@ -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 } : {}),
|
package/worker/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Inbox build. Keep this equal to `version` in cli/package.json. */
|
|
2
|
-
export const VERSION = "0.8.
|
|
2
|
+
export const VERSION = "0.8.28";
|
package/worker/src/wake.ts
CHANGED
|
@@ -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
|
|
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
|
+
});
|