@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 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
- return this.attempt(this.localOverride ?? this.cfg.relay).catch(async (e) => {
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]}`);
@@ -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
@@ -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
- return agentSpawn(resolveNpm() ?? "npm.cmd", args, os);
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
- const git = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
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.27.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
  },