@join-pantheon/cli 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +53 -1
- package/dist/client.js +25 -3
- package/dist/doctor.js +26 -0
- package/dist/network.js +394 -0
- package/dist/update-check.js +2 -1
- package/dist/upgrade.js +2 -1
- package/dist/which.js +5 -3
- package/dist/worker.js +6 -2
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -29,7 +29,8 @@ import { menubarProc, notifierProc, relayProc, workerProc, wiring, logFileFor, d
|
|
|
29
29
|
import { assess } from "./untrusted.js";
|
|
30
30
|
import { report as spendReport, recentRuns } from "./spend.js";
|
|
31
31
|
import { renderBrief, renderTeam } from "./brief.js";
|
|
32
|
-
import { resolveDataDir } from "./config.js";
|
|
32
|
+
import { resolveDataDir, saveAll } from "./config.js";
|
|
33
|
+
import { currentPlan, installNetwork, redactProxy, normalizeProxy, usedSystemFallback } from "./network.js";
|
|
33
34
|
import { createInterface } from "node:readline";
|
|
34
35
|
import { homedir } from "node:os";
|
|
35
36
|
import { removeHook, decideStop, writeIntent, clearIntent, readIntent, installHooks, agentHookStates } from "./notify.js";
|
|
@@ -521,6 +522,7 @@ ${B("control")}
|
|
|
521
522
|
pantheon leave remove what pantheon put on this machine
|
|
522
523
|
pantheon panic cut off all unattended work on this machine, now
|
|
523
524
|
pantheon doctor [--share] check this machine end to end; --share prints it without names or paths, to paste to us
|
|
525
|
+
pantheon network [--proxy <url> | --direct | --clear] how this machine reaches the relay; save a proxy for every Pantheon process here
|
|
524
526
|
pantheon setup three questions — your name, your role, may a woken run execute commands —
|
|
525
527
|
then one screen of defaults to keep or change: who may wake this machine,
|
|
526
528
|
alerts, what you personally decide, playbook, base branch. Re-run any time.
|
|
@@ -549,7 +551,26 @@ ${B("less common")}
|
|
|
549
551
|
pantheon version the pantheon version this machine is running
|
|
550
552
|
`;
|
|
551
553
|
const QUIET = new Set(["mcp", "hook", "pending", "version", "tray-status"]);
|
|
554
|
+
function rememberWorkingProxy() {
|
|
555
|
+
try {
|
|
556
|
+
const plan = currentPlan();
|
|
557
|
+
const via = plan.https ?? plan.http;
|
|
558
|
+
if (!via || (plan.source !== "env" && !(plan.fallbackOnly && usedSystemFallback())))
|
|
559
|
+
return;
|
|
560
|
+
const all = loadAll();
|
|
561
|
+
if (!all || all.network?.source === "pantheon network" || all.network?.proxy === via)
|
|
562
|
+
return;
|
|
563
|
+
const custom = plan.noProxy.filter((h) => !["localhost", "127.0.0.1", "::1", "[::1]"].includes(h));
|
|
564
|
+
saveAll({ ...all, network: { proxy: via, ...(custom.length ? { no_proxy: custom.join(",") } : {}), source: plan.source, saved_at: Date.now() } });
|
|
565
|
+
console.error(D(` saved the proxy ${redactProxy(via)} for this machine's other Pantheon processes (worker, editors) — change it with pantheon network`));
|
|
566
|
+
}
|
|
567
|
+
catch { }
|
|
568
|
+
}
|
|
552
569
|
async function main() {
|
|
570
|
+
try {
|
|
571
|
+
await installNetwork();
|
|
572
|
+
}
|
|
573
|
+
catch { }
|
|
553
574
|
if (!QUIET.has(cmd)) {
|
|
554
575
|
try {
|
|
555
576
|
console.error(D(updateBanner(VERSION)));
|
|
@@ -565,6 +586,35 @@ async function main() {
|
|
|
565
586
|
return interactiveOnboard();
|
|
566
587
|
}
|
|
567
588
|
switch (cmd) {
|
|
589
|
+
case "network": {
|
|
590
|
+
const all = loadAll() ?? die("not configured — sign up or join a team first");
|
|
591
|
+
const set = flag("proxy");
|
|
592
|
+
if (set !== undefined) {
|
|
593
|
+
const p = normalizeProxy(set) ?? die(`"${set}" is not a proxy address — use http://host:port (add user:password@ if it needs a login)`);
|
|
594
|
+
saveAll({ ...all, network: { proxy: p, ...(flag("no-proxy") ? { no_proxy: flag("no-proxy") } : {}), source: "pantheon network", saved_at: Date.now() } });
|
|
595
|
+
console.error(`${B("saved")} every Pantheon process on this machine now reaches the relay through ${redactProxy(p)}`);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (argv.includes("--direct")) {
|
|
599
|
+
saveAll({ ...all, network: { proxy: "", source: "pantheon network --direct", saved_at: Date.now() } });
|
|
600
|
+
console.error(`${B("saved")} direct — set PANTHEON_PROXY=none in a shell to do the same for one command`);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (argv.includes("--clear")) {
|
|
604
|
+
const { network: _drop, ...rest } = all;
|
|
605
|
+
saveAll(rest);
|
|
606
|
+
console.error(`${B("cleared")} — Pantheon follows HTTPS_PROXY / NO_PROXY and the system settings again`);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const plan = currentPlan();
|
|
610
|
+
const via = plan.https ?? plan.http;
|
|
611
|
+
console.log(`${B("network")} ${via ? `through ${redactProxy(via)}` : "direct"}${plan.source !== "none" ? D(` from ${plan.source}${plan.fallbackOnly ? ", used only if a direct connection fails" : ""}`) : ""}`);
|
|
612
|
+
if (via)
|
|
613
|
+
console.log(D(` not for: ${plan.noProxy.join(", ")}`));
|
|
614
|
+
if (plan.pac)
|
|
615
|
+
console.log(D(` this machine uses an auto-config script (${plan.pac}), which Pantheon cannot read — if the relay is unreachable, save the proxy it points to: pantheon network --proxy http://host:port`));
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
568
618
|
case "start": {
|
|
569
619
|
const port = Number(flag("port", "8787"));
|
|
570
620
|
const setupFlags = parseSetupFlags(argv);
|
|
@@ -595,6 +645,7 @@ async function main() {
|
|
|
595
645
|
for (const w of r.warnings)
|
|
596
646
|
console.error(`\x1b[33m ! ${w}\x1b[0m`);
|
|
597
647
|
printGranted(r.granted);
|
|
648
|
+
rememberWorkingProxy();
|
|
598
649
|
if (!r.claimed) {
|
|
599
650
|
console.error(`\n\x1b[31m No team was created.\x1b[0m ${D("Fix the warning above, then run `pantheon start` again.")}`);
|
|
600
651
|
if (r.relay)
|
|
@@ -794,6 +845,7 @@ async function main() {
|
|
|
794
845
|
for (const w of r.warnings)
|
|
795
846
|
console.error(`\x1b[33m ! ${w}\x1b[0m`);
|
|
796
847
|
printGranted(r.granted);
|
|
848
|
+
rememberWorkingProxy();
|
|
797
849
|
printReady(r.tools);
|
|
798
850
|
{
|
|
799
851
|
const joined = load();
|
package/dist/client.js
CHANGED
|
@@ -2,6 +2,7 @@ import WebSocket from "ws";
|
|
|
2
2
|
import { signChallenge } from "./identity.js";
|
|
3
3
|
import { newId } from "./protocol.js";
|
|
4
4
|
import { VERSION } from "./version.js";
|
|
5
|
+
import { describeNetError, explainNetError, trySystemFallback, wsOptions } from "./network.js";
|
|
5
6
|
export class PantheonClient {
|
|
6
7
|
cfg;
|
|
7
8
|
onEvent;
|
|
@@ -74,7 +75,28 @@ export class PantheonClient {
|
|
|
74
75
|
connect() {
|
|
75
76
|
if (!this.everConnected)
|
|
76
77
|
this.closed = false;
|
|
77
|
-
|
|
78
|
+
const first = this.localOverride ?? this.cfg.relay;
|
|
79
|
+
return this.attempt(first).catch(async (e) => {
|
|
80
|
+
if (!this.localOverride && trySystemFallback(first, e)) {
|
|
81
|
+
this.closed = false;
|
|
82
|
+
try {
|
|
83
|
+
return await this.attempt(first);
|
|
84
|
+
}
|
|
85
|
+
catch (e2) {
|
|
86
|
+
e = e2;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return this.afterFailure(e);
|
|
90
|
+
}).catch((e) => {
|
|
91
|
+
if (!explainNetError(e))
|
|
92
|
+
throw e;
|
|
93
|
+
const out = new Error(describeNetError(e));
|
|
94
|
+
out.code = e.code;
|
|
95
|
+
throw out;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
async afterFailure(e) {
|
|
99
|
+
{
|
|
78
100
|
if (this.localOverride)
|
|
79
101
|
throw e;
|
|
80
102
|
const transport = /ETIMEDOUT|ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ENETUNREACH|handshake has timed out|socket hang up/i
|
|
@@ -88,13 +110,13 @@ export class PantheonClient {
|
|
|
88
110
|
this.localOverride = local;
|
|
89
111
|
this.usingLoopback = true;
|
|
90
112
|
return this.attempt(local);
|
|
91
|
-
}
|
|
113
|
+
}
|
|
92
114
|
}
|
|
93
115
|
attempt(address) {
|
|
94
116
|
return new Promise((resolve, reject) => {
|
|
95
117
|
let settled = false;
|
|
96
118
|
this.ws?.terminate();
|
|
97
|
-
const ws = new WebSocket(address, { handshakeTimeout: 6000 });
|
|
119
|
+
const ws = new WebSocket(address, { handshakeTimeout: 6000, ...wsOptions(address) });
|
|
98
120
|
this.ws = ws;
|
|
99
121
|
ws.on("open", () => { });
|
|
100
122
|
ws.on("message", (raw) => {
|
package/dist/doctor.js
CHANGED
|
@@ -20,6 +20,7 @@ import { onPathNamed } from "./which.js";
|
|
|
20
20
|
import { VERSION } from "./version.js";
|
|
21
21
|
import { serviceInstalled, servicesUnavailable } from "./service.js";
|
|
22
22
|
import { hostEnv, platformName } from "./host-env.js";
|
|
23
|
+
import { currentPlan, explainNetError, proxyFor, redactProxy } from "./network.js";
|
|
23
24
|
const HOSTED_RELAY = "relay.joinpantheon.network";
|
|
24
25
|
const git = (args, cwd) => {
|
|
25
26
|
try {
|
|
@@ -222,6 +223,20 @@ export async function doctor(projectDir) {
|
|
|
222
223
|
else {
|
|
223
224
|
add("pass", "transport", url.protocol === "wss:" ? "wss:// (TLS)" : `ws:// on a private address (${url.host})`, undefined, url.protocol === "wss:" ? "wss:// (TLS)" : "ws:// on a private address");
|
|
224
225
|
}
|
|
226
|
+
const plan = currentPlan();
|
|
227
|
+
const via = proxyFor(cfg.relay, plan);
|
|
228
|
+
if (via) {
|
|
229
|
+
add("pass", "network", `through the proxy ${redactProxy(via)} (from ${plan.source})`, undefined, `through a proxy (${plan.source})`);
|
|
230
|
+
}
|
|
231
|
+
else if (plan.fallbackOnly && (plan.https || plan.http)) {
|
|
232
|
+
add("pass", "network", `direct; the ${plan.source === "macos" ? "macOS" : "Windows"} proxy ${redactProxy(plan.https ?? plan.http)} is used only if a direct connection fails`, undefined, `direct, system proxy as fallback`);
|
|
233
|
+
}
|
|
234
|
+
else if (plan.pac) {
|
|
235
|
+
add("warn", "network", `direct, but this machine uses a proxy auto-config script, which Pantheon cannot read`, "if the relay turns out unreachable below, save the proxy that script points to: pantheon network --proxy http://host:port", "direct; PAC script present");
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
add("pass", "network", "direct — no proxy set", undefined, "direct");
|
|
239
|
+
}
|
|
225
240
|
const health = `${cfg.relay.replace(/^ws/, "http")}/health`;
|
|
226
241
|
const where = hosted ? "hosted" : "self-hosted";
|
|
227
242
|
try {
|
|
@@ -245,6 +260,11 @@ export async function doctor(projectDir) {
|
|
|
245
260
|
}
|
|
246
261
|
catch (e) {
|
|
247
262
|
const msg = e.message;
|
|
263
|
+
const nx = explainNetError(e);
|
|
264
|
+
if (nx) {
|
|
265
|
+
add("fail", "relay", `cannot reach ${health} — ${nx.why}`, nx.fix, `unreachable (${where}): ${nx.share}`);
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
248
268
|
add("fail", "relay", `cannot reach ${health} — ${msg}`, "start it with `pantheon relay`, or check the tunnel/tailnet. Your teammate must be able to reach the SAME address.", `unreachable (${where}): ${/abort|timeout/i.test(msg) ? "timed out" : /ECONNREFUSED/.test(msg) ? "connection refused" : /ENOTFOUND|EAI_AGAIN/.test(msg) ? "name did not resolve" : "fetch failed"}`);
|
|
249
269
|
return out;
|
|
250
270
|
}
|
|
@@ -252,6 +272,12 @@ export async function doctor(projectDir) {
|
|
|
252
272
|
let joined = false;
|
|
253
273
|
await client.connect().then(() => { joined = true; }).catch(() => { });
|
|
254
274
|
if (!joined) {
|
|
275
|
+
const nx = client.pending ? null : explainNetError(new Error(client.lastError ?? ""));
|
|
276
|
+
if (nx) {
|
|
277
|
+
add("fail", "live link", `the health check got through but the live connection did not — ${nx.why}`, nx.fix, `live connection failed: ${nx.share}`);
|
|
278
|
+
client.close();
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
255
281
|
client.pending
|
|
256
282
|
? add("fail", "membership", "registered but not admitted", `ask an admitted member to run: pantheon admit ${cfg.member} --may-wake (confirm fingerprint ${fingerprint(cfg.publicKey)} with them by voice first)`, "registered, not admitted")
|
|
257
283
|
: add("fail", "membership", `refused: ${client.lastError ?? "unknown"}`, "if this is KEY_MISMATCH, someone else already registered your member name — pick another with --me, or have them revoke it", `refused: ${String(client.lastError ?? "unknown").split(/[\s:]/)[0]}`);
|
package/dist/network.js
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import https from "node:https";
|
|
5
|
+
import net from "node:net";
|
|
6
|
+
import tls from "node:tls";
|
|
7
|
+
import { platform } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
const LOOPBACK = ["localhost", "127.0.0.1", "::1", "[::1]"];
|
|
10
|
+
const NONE = { https: null, http: null, noProxy: LOOPBACK, source: "none", fallbackOnly: false };
|
|
11
|
+
const pick = (env, ...names) => {
|
|
12
|
+
for (const n of names) {
|
|
13
|
+
const v = env[n]?.trim();
|
|
14
|
+
if (v)
|
|
15
|
+
return v;
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
};
|
|
19
|
+
export function normalizeProxy(raw) {
|
|
20
|
+
const v = raw.trim();
|
|
21
|
+
if (!v)
|
|
22
|
+
return null;
|
|
23
|
+
try {
|
|
24
|
+
const u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(v) ? v : `http://${v}`);
|
|
25
|
+
if (u.protocol !== "http:" && u.protocol !== "https:")
|
|
26
|
+
return null;
|
|
27
|
+
if (!u.hostname)
|
|
28
|
+
return null;
|
|
29
|
+
return u.toString().replace(/\/$/, "");
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function redactProxy(url) {
|
|
36
|
+
if (!url)
|
|
37
|
+
return "direct";
|
|
38
|
+
try {
|
|
39
|
+
const u = new URL(url);
|
|
40
|
+
if (u.username || u.password) {
|
|
41
|
+
u.username = u.username ? "***" : "";
|
|
42
|
+
u.password = "";
|
|
43
|
+
}
|
|
44
|
+
return u.toString().replace(/\/$/, "");
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return "(unparseable)";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function splitNoProxy(raw) {
|
|
51
|
+
return [...new Set([...(raw ?? "").split(/[,\s]+/).map((s) => s.trim().toLowerCase()).filter(Boolean), ...LOOPBACK])];
|
|
52
|
+
}
|
|
53
|
+
export function bypassesProxy(host, port, noProxy) {
|
|
54
|
+
const h = host.toLowerCase().replace(/^\[|\]$/g, "");
|
|
55
|
+
for (const raw of noProxy) {
|
|
56
|
+
if (raw === "*")
|
|
57
|
+
return true;
|
|
58
|
+
let entry = raw.replace(/^\[|\]$/g, "");
|
|
59
|
+
let entryPort;
|
|
60
|
+
const m = /^(.*):(\d+)$/.exec(entry);
|
|
61
|
+
if (m && !entry.includes("::")) {
|
|
62
|
+
entry = m[1];
|
|
63
|
+
entryPort = m[2];
|
|
64
|
+
}
|
|
65
|
+
if (entryPort && entryPort !== port)
|
|
66
|
+
continue;
|
|
67
|
+
if (entry.includes("/") && net.isIP(h)) {
|
|
68
|
+
if (inCidr(h, entry))
|
|
69
|
+
return true;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const bare = entry.replace(/^\*?\./, "");
|
|
73
|
+
if (h === bare || h.endsWith(`.${bare}`))
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
function inCidr(ip, cidr) {
|
|
79
|
+
const [base, bitsRaw] = cidr.split("/");
|
|
80
|
+
const bits = Number(bitsRaw);
|
|
81
|
+
if (!base || net.isIPv4(ip) !== net.isIPv4(base) || !net.isIPv4(ip) || !(bits >= 0 && bits <= 32))
|
|
82
|
+
return false;
|
|
83
|
+
const n = (s) => s.split(".").reduce((a, o) => (a << 8) + Number(o), 0) >>> 0;
|
|
84
|
+
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
|
|
85
|
+
return (n(ip) & mask) === (n(base) & mask);
|
|
86
|
+
}
|
|
87
|
+
export function parseScutil(out) {
|
|
88
|
+
const val = (k) => new RegExp(`^\\s*${k}\\s*:\\s*(.+?)\\s*$`, "m").exec(out)?.[1];
|
|
89
|
+
const on = (k) => val(k) === "1";
|
|
90
|
+
const hp = (host, port) => (host ? normalizeProxy(`${host}${port ? `:${port}` : ""}`) : null);
|
|
91
|
+
const exceptions = [];
|
|
92
|
+
const block = /ExceptionsList\s*:\s*<array>\s*\{([\s\S]*?)\}/.exec(out)?.[1] ?? "";
|
|
93
|
+
for (const line of block.split("\n")) {
|
|
94
|
+
const m = /:\s*(\S+)\s*$/.exec(line);
|
|
95
|
+
if (m)
|
|
96
|
+
exceptions.push(m[1]);
|
|
97
|
+
}
|
|
98
|
+
const pac = on("ProxyAutoConfigEnable") ? val("ProxyAutoConfigURLString") : undefined;
|
|
99
|
+
return {
|
|
100
|
+
https: on("HTTPSEnable") ? hp(val("HTTPSProxy"), val("HTTPSPort")) : null,
|
|
101
|
+
http: on("HTTPEnable") ? hp(val("HTTPProxy"), val("HTTPPort")) : null,
|
|
102
|
+
noProxy: exceptions,
|
|
103
|
+
...(pac ? { pac } : {}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
export function parseWindowsReg(out) {
|
|
107
|
+
const val = (k) => new RegExp(`^\\s*${k}\\s+REG_\\w+\\s+(.*?)\\s*$`, "mi").exec(out)?.[1];
|
|
108
|
+
const pac = val("AutoConfigURL") || undefined;
|
|
109
|
+
const enabled = /^0x0*1$/i.test(val("ProxyEnable") ?? "");
|
|
110
|
+
const server = enabled ? val("ProxyServer") ?? "" : "";
|
|
111
|
+
let httpsP = null, httpP = null;
|
|
112
|
+
if (server.includes("=")) {
|
|
113
|
+
for (const part of server.split(";")) {
|
|
114
|
+
const [k, v] = part.split("=");
|
|
115
|
+
if (k?.trim().toLowerCase() === "https" && v)
|
|
116
|
+
httpsP = normalizeProxy(v);
|
|
117
|
+
if (k?.trim().toLowerCase() === "http" && v)
|
|
118
|
+
httpP = normalizeProxy(v);
|
|
119
|
+
}
|
|
120
|
+
httpsP ??= httpP;
|
|
121
|
+
}
|
|
122
|
+
else if (server) {
|
|
123
|
+
httpsP = httpP = normalizeProxy(server);
|
|
124
|
+
}
|
|
125
|
+
const override = (val("ProxyOverride") ?? "").split(";").map((s) => s.trim()).filter((s) => s && s !== "<local>");
|
|
126
|
+
return { https: httpsP, http: httpP, noProxy: override, ...(pac ? { pac } : {}) };
|
|
127
|
+
}
|
|
128
|
+
const run = (cmd, args) => {
|
|
129
|
+
try {
|
|
130
|
+
return execFileSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000 });
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
function savedFromConfig(env) {
|
|
137
|
+
try {
|
|
138
|
+
const home = env.PANTHEON_HOME ?? join(env.HOME ?? env.USERPROFILE ?? "", ".pantheon");
|
|
139
|
+
const f = join(home, "config.json");
|
|
140
|
+
if (!existsSync(f))
|
|
141
|
+
return null;
|
|
142
|
+
return JSON.parse(readFileSync(f, "utf8")).network ?? null;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export const defaultNetworkDeps = () => ({
|
|
149
|
+
env: process.env,
|
|
150
|
+
os: platform(),
|
|
151
|
+
saved: () => savedFromConfig(process.env),
|
|
152
|
+
scutil: () => run("/usr/sbin/scutil", ["--proxy"]),
|
|
153
|
+
winReg: () => run("reg", ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings"]),
|
|
154
|
+
});
|
|
155
|
+
export function resolveProxyPlan(d = defaultNetworkDeps()) {
|
|
156
|
+
const noProxy = splitNoProxy(pick(d.env, "no_proxy", "NO_PROXY"));
|
|
157
|
+
const forced = d.env.PANTHEON_PROXY?.trim();
|
|
158
|
+
if (forced) {
|
|
159
|
+
if (/^(none|direct|off|false|0)$/i.test(forced))
|
|
160
|
+
return { ...NONE, noProxy };
|
|
161
|
+
const p = normalizeProxy(forced);
|
|
162
|
+
if (p)
|
|
163
|
+
return { https: p, http: p, noProxy, source: "PANTHEON_PROXY", fallbackOnly: false };
|
|
164
|
+
}
|
|
165
|
+
const envHttps = pick(d.env, "https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY");
|
|
166
|
+
const envHttp = pick(d.env, "http_proxy", "HTTP_PROXY", "all_proxy", "ALL_PROXY");
|
|
167
|
+
if (envHttps || envHttp) {
|
|
168
|
+
const hs = envHttps ? normalizeProxy(envHttps) : null;
|
|
169
|
+
const h = envHttp ? normalizeProxy(envHttp) : null;
|
|
170
|
+
if (hs || h)
|
|
171
|
+
return { https: hs, http: h, noProxy, source: "env", fallbackOnly: false };
|
|
172
|
+
}
|
|
173
|
+
const saved = d.saved();
|
|
174
|
+
if (saved && saved.proxy === "")
|
|
175
|
+
return { ...NONE, noProxy };
|
|
176
|
+
const sp = saved?.proxy ? normalizeProxy(saved.proxy) : null;
|
|
177
|
+
if (sp)
|
|
178
|
+
return { https: sp, http: sp, noProxy: splitNoProxy([saved?.no_proxy, ...noProxy].filter(Boolean).join(",")), source: "config", fallbackOnly: false };
|
|
179
|
+
const sys = d.os === "darwin" ? (() => { const o = d.scutil(); return o ? parseScutil(o) : null; })()
|
|
180
|
+
: d.os === "win32" ? (() => { const o = d.winReg(); return o ? parseWindowsReg(o) : null; })()
|
|
181
|
+
: null;
|
|
182
|
+
if (sys && (sys.https || sys.http)) {
|
|
183
|
+
return { https: sys.https ?? sys.http, http: sys.http ?? sys.https, noProxy: splitNoProxy([...sys.noProxy, ...noProxy].join(",")),
|
|
184
|
+
source: d.os === "darwin" ? "macos" : "windows", fallbackOnly: true, ...(sys.pac ? { pac: sys.pac } : {}) };
|
|
185
|
+
}
|
|
186
|
+
return { ...NONE, noProxy, ...(sys?.pac ? { pac: sys.pac } : {}) };
|
|
187
|
+
}
|
|
188
|
+
export function proxyFor(target, plan) {
|
|
189
|
+
let u;
|
|
190
|
+
try {
|
|
191
|
+
u = new URL(target);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const secure = u.protocol === "https:" || u.protocol === "wss:";
|
|
197
|
+
const port = u.port || (secure ? "443" : "80");
|
|
198
|
+
const p = secure ? plan.https : plan.http;
|
|
199
|
+
if (!p || bypassesProxy(u.hostname, port, plan.noProxy))
|
|
200
|
+
return null;
|
|
201
|
+
return p;
|
|
202
|
+
}
|
|
203
|
+
export function tunnelAgent(proxyUrl, secure) {
|
|
204
|
+
const proxy = new URL(proxyUrl);
|
|
205
|
+
const auth = proxy.username || proxy.password
|
|
206
|
+
? `Basic ${Buffer.from(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`).toString("base64")}`
|
|
207
|
+
: undefined;
|
|
208
|
+
const Base = secure ? https.Agent : http.Agent;
|
|
209
|
+
class Tunnel extends Base {
|
|
210
|
+
createConnection(options, callback) {
|
|
211
|
+
const cb = (err, s) => callback?.(err, s);
|
|
212
|
+
const host = String(options.hostname ?? options.host ?? "");
|
|
213
|
+
const port = Number(options.port ?? (secure ? 443 : 80));
|
|
214
|
+
const req = (proxy.protocol === "https:" ? https : http).request({
|
|
215
|
+
host: proxy.hostname, port: Number(proxy.port || (proxy.protocol === "https:" ? 443 : 80)),
|
|
216
|
+
method: "CONNECT", path: `${host.includes(":") ? `[${host}]` : host}:${port}`,
|
|
217
|
+
headers: { host: `${host}:${port}`, ...(auth ? { "proxy-authorization": auth } : {}) },
|
|
218
|
+
timeout: 10_000,
|
|
219
|
+
});
|
|
220
|
+
let done = false;
|
|
221
|
+
const fail = (e) => { if (!done) {
|
|
222
|
+
done = true;
|
|
223
|
+
cb(e);
|
|
224
|
+
} };
|
|
225
|
+
req.once("connect", (res, socket) => {
|
|
226
|
+
if (res.statusCode !== 200) {
|
|
227
|
+
socket.destroy();
|
|
228
|
+
const err = new Error(`the proxy ${redactProxy(proxyUrl)} answered ${res.statusCode} ${res.statusMessage ?? ""} to a tunnel request for ${host}:${port}`.trim());
|
|
229
|
+
err.code = res.statusCode === 407 ? "PROXY_AUTH_REQUIRED" : "PROXY_REFUSED";
|
|
230
|
+
return fail(err);
|
|
231
|
+
}
|
|
232
|
+
if (!secure) {
|
|
233
|
+
done = true;
|
|
234
|
+
return cb(null, socket);
|
|
235
|
+
}
|
|
236
|
+
const t = tls.connect({ ...options, socket, servername: net.isIP(host) ? undefined : host });
|
|
237
|
+
t.once("secureConnect", () => { if (!done) {
|
|
238
|
+
done = true;
|
|
239
|
+
cb(null, t);
|
|
240
|
+
} });
|
|
241
|
+
t.once("error", fail);
|
|
242
|
+
});
|
|
243
|
+
req.once("timeout", () => { req.destroy(Object.assign(new Error(`the proxy ${redactProxy(proxyUrl)} did not answer within 10s`), { code: "ETIMEDOUT" })); });
|
|
244
|
+
req.once("error", fail);
|
|
245
|
+
req.end();
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return new Tunnel({ keepAlive: false });
|
|
250
|
+
}
|
|
251
|
+
let installed = null;
|
|
252
|
+
let fallbackUsed = false;
|
|
253
|
+
export function wsOptions(address) {
|
|
254
|
+
const plan = currentPlan();
|
|
255
|
+
if (plan.fallbackOnly && !fallbackUsed)
|
|
256
|
+
return {};
|
|
257
|
+
const p = proxyFor(address, plan);
|
|
258
|
+
if (!p)
|
|
259
|
+
return {};
|
|
260
|
+
return { agent: tunnelAgent(p, /^wss:/.test(address)) };
|
|
261
|
+
}
|
|
262
|
+
export function currentPlan() {
|
|
263
|
+
if (!installed)
|
|
264
|
+
installed = { plan: resolveProxyPlan(), active: null };
|
|
265
|
+
return installed.plan;
|
|
266
|
+
}
|
|
267
|
+
export async function installNetwork() {
|
|
268
|
+
const plan = currentPlan();
|
|
269
|
+
if (plan.https || plan.http) {
|
|
270
|
+
if (!plan.fallbackOnly)
|
|
271
|
+
await useProxyForFetch(plan);
|
|
272
|
+
else
|
|
273
|
+
wrapFetchWithFallback(plan);
|
|
274
|
+
}
|
|
275
|
+
return plan;
|
|
276
|
+
}
|
|
277
|
+
async function useProxyForFetch(plan) {
|
|
278
|
+
if (installed?.active)
|
|
279
|
+
return;
|
|
280
|
+
const { EnvHttpProxyAgent, setGlobalDispatcher } = await import("undici");
|
|
281
|
+
setGlobalDispatcher(new EnvHttpProxyAgent({
|
|
282
|
+
...(plan.https ? { httpsProxy: plan.https } : {}),
|
|
283
|
+
...(plan.http ? { httpProxy: plan.http } : {}),
|
|
284
|
+
noProxy: plan.noProxy.join(","),
|
|
285
|
+
}));
|
|
286
|
+
if (installed)
|
|
287
|
+
installed.active = plan.https ?? plan.http;
|
|
288
|
+
}
|
|
289
|
+
const BLOCKED = /ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|EPROTO|wrong version number|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET|handshake has timed out|socket hang up/i;
|
|
290
|
+
export const isBlockedPath = (e) => {
|
|
291
|
+
const err = e;
|
|
292
|
+
return BLOCKED.test(`${err?.code ?? ""} ${err?.message ?? ""} ${err?.cause?.code ?? ""} ${err?.cause?.message ?? ""}`);
|
|
293
|
+
};
|
|
294
|
+
function wrapFetchWithFallback(plan) {
|
|
295
|
+
const g = globalThis;
|
|
296
|
+
if (g.__pantheonFetch)
|
|
297
|
+
return;
|
|
298
|
+
g.__pantheonFetch = true;
|
|
299
|
+
const direct = g.fetch.bind(globalThis);
|
|
300
|
+
g.fetch = (async (input, init) => {
|
|
301
|
+
if (fallbackUsed)
|
|
302
|
+
return direct(input, init);
|
|
303
|
+
try {
|
|
304
|
+
return await direct(input, init);
|
|
305
|
+
}
|
|
306
|
+
catch (e) {
|
|
307
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
308
|
+
if (!isBlockedPath(e) || !proxyFor(url, plan))
|
|
309
|
+
throw e;
|
|
310
|
+
await useProxyForFetch(plan);
|
|
311
|
+
fallbackUsed = true;
|
|
312
|
+
return direct(input, init);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
export function trySystemFallback(address, e) {
|
|
317
|
+
const plan = currentPlan();
|
|
318
|
+
if (!plan.fallbackOnly || fallbackUsed || !isBlockedPath(e) || !proxyFor(address, plan))
|
|
319
|
+
return false;
|
|
320
|
+
fallbackUsed = true;
|
|
321
|
+
void useProxyForFetch(plan);
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
export const usedSystemFallback = () => fallbackUsed;
|
|
325
|
+
export function proxyEnvFor(plan = currentPlan()) {
|
|
326
|
+
if (!(plan.https || plan.http) || plan.source === "env")
|
|
327
|
+
return {};
|
|
328
|
+
if (plan.fallbackOnly && !fallbackUsed)
|
|
329
|
+
return {};
|
|
330
|
+
return {
|
|
331
|
+
...(plan.https ? { HTTPS_PROXY: plan.https, https_proxy: plan.https } : {}),
|
|
332
|
+
...(plan.http ? { HTTP_PROXY: plan.http, http_proxy: plan.http } : {}),
|
|
333
|
+
NO_PROXY: plan.noProxy.join(","), no_proxy: plan.noProxy.join(","),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
export function resetNetworkForTests(plan) {
|
|
337
|
+
installed = plan ? { plan, active: null } : null;
|
|
338
|
+
fallbackUsed = false;
|
|
339
|
+
const g = globalThis;
|
|
340
|
+
delete g.__pantheonFetch;
|
|
341
|
+
}
|
|
342
|
+
export function explainNetError(e, plan = currentPlan()) {
|
|
343
|
+
const err = e;
|
|
344
|
+
const text = `${err?.code ?? ""} ${err?.message ?? ""} ${err?.cause?.code ?? ""} ${err?.cause?.message ?? ""}`;
|
|
345
|
+
const proxy = plan.https ?? plan.http;
|
|
346
|
+
const shown = proxy ? redactProxy(proxy) : "";
|
|
347
|
+
const orProxy = "Behind a company proxy? pantheon network --proxy http://host:port";
|
|
348
|
+
if (/PROXY_AUTH_REQUIRED|\b407\b/.test(text)) {
|
|
349
|
+
return { why: `the proxy ${shown} asked for a login`, fix: "put it in the proxy address: http://user:password@host:port", share: "proxy requires authentication" };
|
|
350
|
+
}
|
|
351
|
+
if (/PROXY_REFUSED/.test(text)) {
|
|
352
|
+
return { why: `the proxy ${shown} would not open a connection to the relay`, fix: "ask whoever runs it to allow HTTPS to relay.joinpantheon.network", share: "proxy refused the tunnel" };
|
|
353
|
+
}
|
|
354
|
+
if (/wrong version number|EPROTO|packet length too long|unknown protocol/i.test(text)) {
|
|
355
|
+
return proxy
|
|
356
|
+
? { why: `the proxy ${shown} answered in plain text where a secure connection was expected`, fix: "check its address: company proxies are usually http://host:port, even for secure traffic", share: "TLS answered in plain text (via proxy)" }
|
|
357
|
+
: { why: "this network answered the relay's secure connection in plain text, which is what a network that requires a proxy does", fix: "set the proxy and run it again: export HTTPS_PROXY=http://host:port (or save it: pantheon network --proxy http://host:port)", share: "TLS answered in plain text (direct) — likely a required proxy" };
|
|
358
|
+
}
|
|
359
|
+
if (/SELF_SIGNED_CERT_IN_CHAIN|UNABLE_TO_GET_ISSUER_CERT_LOCALLY|UNABLE_TO_VERIFY_LEAF_SIGNATURE|self[- ]signed certificate|unable to get local issuer/i.test(text)) {
|
|
360
|
+
return { why: "the relay's certificate was swapped for one this machine does not trust, which happens on networks that inspect secure traffic", fix: "point Node at your company's root certificate: export NODE_EXTRA_CA_CERTS=/path/to/root.pem (or on Node 22.15+: NODE_USE_SYSTEM_CA=1)", share: "certificate not trusted — TLS inspection" };
|
|
361
|
+
}
|
|
362
|
+
if (/CERT_HAS_EXPIRED|certificate has expired|ERR_TLS_CERT_ALTNAME_INVALID|Hostname\/IP does not match/i.test(text)) {
|
|
363
|
+
return { why: "the relay's certificate looks expired or belongs to another name", fix: "check this machine's date and time, then try another network", share: "certificate expired or for another name" };
|
|
364
|
+
}
|
|
365
|
+
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(text)) {
|
|
366
|
+
return proxy
|
|
367
|
+
? { why: `the proxy ${shown} could not find the relay's address`, fix: "check the relay address with pantheon doctor", share: "name did not resolve" }
|
|
368
|
+
: { why: "the relay's address did not resolve — this machine may be offline", fix: `check the connection and try again. ${orProxy}`, share: "name did not resolve" };
|
|
369
|
+
}
|
|
370
|
+
if (/ECONNREFUSED/i.test(text)) {
|
|
371
|
+
return proxy
|
|
372
|
+
? { why: `nothing is accepting connections at the proxy ${shown}`, fix: "check the proxy address and port, or clear it: pantheon network --clear", share: "proxy refused the connection" }
|
|
373
|
+
: { why: "nothing is accepting connections at the relay's address — it may be down or restarting", fix: "try again in a minute; pantheon doctor shows more", share: "connection refused" };
|
|
374
|
+
}
|
|
375
|
+
if (/ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|handshake has timed out|timed out|EHOSTUNREACH|ENETUNREACH/i.test(text)) {
|
|
376
|
+
return proxy
|
|
377
|
+
? { why: `the proxy ${shown} did not answer in time`, fix: "check it is reachable from this machine, or clear it: pantheon network --clear", share: "connection timed out" }
|
|
378
|
+
: { why: "the relay did not answer in time", fix: `check this machine is online and try again. ${orProxy}`, share: "connection timed out" };
|
|
379
|
+
}
|
|
380
|
+
if (/ECONNRESET|socket hang up/i.test(text)) {
|
|
381
|
+
return proxy
|
|
382
|
+
? { why: `the connection through the proxy ${shown} was cut`, fix: "the proxy may not allow WebSockets; ask for relay.joinpantheon.network to be allowed", share: "connection reset" }
|
|
383
|
+
: { why: "the connection dropped before the relay answered", fix: "try again", share: "connection reset" };
|
|
384
|
+
}
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
export function describeNetError(e, plan) {
|
|
388
|
+
const raw = e?.message ?? String(e);
|
|
389
|
+
const x = explainNetError(e, plan);
|
|
390
|
+
const first = (raw.split("\n")[0] ?? "").slice(0, 120);
|
|
391
|
+
const fix = x ? x.fix.charAt(0).toUpperCase() + x.fix.slice(1) : "";
|
|
392
|
+
return x ? `${x.why}. ${fix}. (${first})`.replace(/\.\. \(/, ". (").replace(/\?\. \(/, "? (") : raw;
|
|
393
|
+
}
|
|
394
|
+
//# sourceMappingURL=network.js.map
|
package/dist/update-check.js
CHANGED
|
@@ -5,6 +5,7 @@ import { CONFIG_DIR } from "./config.js";
|
|
|
5
5
|
import { repoRoot, PKG_NAME } from "./upgrade.js";
|
|
6
6
|
import { VERSION } from "./version.js";
|
|
7
7
|
import { npmSpawn } from "./which.js";
|
|
8
|
+
import { proxyEnvFor } from "./network.js";
|
|
8
9
|
export function semverGt(x, y) {
|
|
9
10
|
const p = (s) => s.split(".").map((n) => Number(n) || 0);
|
|
10
11
|
const [a, b] = [p(x), p(y)];
|
|
@@ -81,7 +82,7 @@ export function defaultDeps(home) {
|
|
|
81
82
|
},
|
|
82
83
|
npmLatest: () => {
|
|
83
84
|
try {
|
|
84
|
-
const s = npmSpawn(["view", PKG_NAME, "version"]);
|
|
85
|
+
const s = npmSpawn(["view", PKG_NAME, "version"], undefined, undefined, proxyEnvFor());
|
|
85
86
|
return execFileSync(s.command, s.args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000, ...s.options }).trim() || null;
|
|
86
87
|
}
|
|
87
88
|
catch {
|
package/dist/upgrade.js
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { installForUser } from "./install.js";
|
|
6
6
|
import { npmSpawn } from "./which.js";
|
|
7
|
+
import { proxyEnvFor } from "./network.js";
|
|
7
8
|
import { relayProc, workerProc, notifierProc, menubarProc } from "./machine.js";
|
|
8
9
|
export function repoRoot() {
|
|
9
10
|
return join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -30,7 +31,7 @@ export function rewireVia(root, fallback, run = (file, args) => execFileSync(pro
|
|
|
30
31
|
export function defaultDeps() {
|
|
31
32
|
const git = (root, ...args) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
32
33
|
const npm = (args, opts) => {
|
|
33
|
-
const s = npmSpawn(args);
|
|
34
|
+
const s = npmSpawn(args, undefined, undefined, proxyEnvFor());
|
|
34
35
|
return execFileSync(s.command, s.args, { encoding: "utf8", ...opts, ...s.options });
|
|
35
36
|
};
|
|
36
37
|
return {
|
package/dist/which.js
CHANGED
|
@@ -76,10 +76,12 @@ export function spawnShape(binary, os = platform()) {
|
|
|
76
76
|
return { command: binary, shell: false };
|
|
77
77
|
return { command: binary, shell: /\.(cmd|bat)$/i.test(binary) };
|
|
78
78
|
}
|
|
79
|
-
export function npmSpawn(args, os = platform(), resolveNpm = () => which("npm", os)) {
|
|
79
|
+
export function npmSpawn(args, os = platform(), resolveNpm = () => which("npm", os), proxyEnv = {}) {
|
|
80
|
+
const env = Object.keys(proxyEnv).length ? { env: { ...process.env, ...proxyEnv } } : {};
|
|
80
81
|
if (!isWindows(os))
|
|
81
|
-
return { command: "npm", args: [...args], options: {} };
|
|
82
|
-
|
|
82
|
+
return { command: "npm", args: [...args], options: { ...env } };
|
|
83
|
+
const s = agentSpawn(resolveNpm() ?? "npm.cmd", args, os);
|
|
84
|
+
return { ...s, options: { ...s.options, ...env } };
|
|
83
85
|
}
|
|
84
86
|
const cmdQuote = (a) => (/[\s"&|<>^()]/.test(a) || a === "" ? `"${a.replace(/"/g, '\\"')}"` : a);
|
|
85
87
|
export function agentSpawn(binary, args, os = platform()) {
|
package/dist/worker.js
CHANGED
|
@@ -19,7 +19,11 @@ import { diagnoseRun, gitProbe } from "./run-failure.js";
|
|
|
19
19
|
import { installedTools, preferredTool } from "./notifier.js";
|
|
20
20
|
import { loadPersonas, matchPersona, personaByName, personaMatches, effectivePolicy, personaBrief, describePersona, } from "./persona.js";
|
|
21
21
|
import { isContinuationFor, LIMITS, shouldDeferToHuman } from "./protocol.js";
|
|
22
|
-
|
|
22
|
+
import { proxyEnvFor } from "./network.js";
|
|
23
|
+
const git = (cwd, ...args) => {
|
|
24
|
+
const px = proxyEnvFor();
|
|
25
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...(Object.keys(px).length ? { env: { ...process.env, ...px } } : {}) }).trim();
|
|
26
|
+
};
|
|
23
27
|
const CLOCK_SKEW_MS = 5_000;
|
|
24
28
|
const isRepo = (cwd) => {
|
|
25
29
|
try {
|
|
@@ -583,7 +587,7 @@ export class Worker {
|
|
|
583
587
|
let command;
|
|
584
588
|
let agentId;
|
|
585
589
|
let args;
|
|
586
|
-
let env = { ...process.env };
|
|
590
|
+
let env = { ...process.env, ...proxyEnvFor() };
|
|
587
591
|
try {
|
|
588
592
|
const runHome = this.runHome();
|
|
589
593
|
agentId = this.opts.agentCmd ? "claude-code" : undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@join-pantheon/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Let your coding agent collaborate directly with your teammates' agents — ask, agree an interface, and deliver, without a human relaying messages.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
25
|
+
"undici": "^7.29.1",
|
|
25
26
|
"ws": "^8.18.0",
|
|
26
27
|
"zod": "^3.23.8"
|
|
27
28
|
},
|