@tiens.nguyen/gu-cli 1.0.686

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.
Files changed (43) hide show
  1. package/README.md +52 -0
  2. package/agent-model-command.mjs +259 -0
  3. package/agent-model-label.mjs +159 -0
  4. package/clear-state.mjs +149 -0
  5. package/client-expert-api.mjs +736 -0
  6. package/client-expert-run.mjs +892 -0
  7. package/client-expert-setup.mjs +616 -0
  8. package/coding-choice-tags.mjs +69 -0
  9. package/coding-key-prompt.mjs +229 -0
  10. package/coding-provider-setup.mjs +808 -0
  11. package/completed-flush.mjs +105 -0
  12. package/daemon-control.mjs +462 -0
  13. package/device-login.mjs +212 -0
  14. package/doctor-check.mjs +239 -0
  15. package/embed-model-command.mjs +157 -0
  16. package/first-run-steps.mjs +171 -0
  17. package/gonext_agent_chat.py +12299 -0
  18. package/gonext_mlx_embed.py +155 -0
  19. package/gonext_probe_agent.py +93 -0
  20. package/gonext_transcribe.py +130 -0
  21. package/gu-cli.mjs +4930 -0
  22. package/gu-repl.mjs +10326 -0
  23. package/job-pools.mjs +89 -0
  24. package/model-doctor.mjs +1494 -0
  25. package/node-version.mjs +40 -0
  26. package/ollama-setup.mjs +832 -0
  27. package/package.json +100 -0
  28. package/platform-tools.mjs +520 -0
  29. package/poll-errors.mjs +141 -0
  30. package/proxy-command.mjs +165 -0
  31. package/proxy-config.mjs +255 -0
  32. package/proxy-dispatcher.mjs +132 -0
  33. package/proxy-selftest.mjs +234 -0
  34. package/proxy-store.mjs +69 -0
  35. package/rag-job-config.mjs +59 -0
  36. package/rag-selftest.mjs +215 -0
  37. package/s3-setup.mjs +85 -0
  38. package/terminal-copy.mjs +248 -0
  39. package/terminal-hover.mjs +153 -0
  40. package/terminal-layout.mjs +2507 -0
  41. package/terminal-viewport.mjs +602 -0
  42. package/thinking_words.txt +1003 -0
  43. package/version-check.mjs +72 -0
@@ -0,0 +1,141 @@
1
+ // Friendly poll-failure reporting for the worker daemon.
2
+ //
3
+ // The poll loop runs every ~500ms, so a failure that lasts (an API outage, a rotated key)
4
+ // used to fill ~/.gonext/worker.log with THOUSANDS of identical 4-line stack traces — which
5
+ // says nothing about what's wrong or whether the user can fix it. This turns each failure
6
+ // into ONE plain sentence plus an actionable hint, and collapses repeats of the same failure
7
+ // into a periodic "still failing" line.
8
+ //
9
+ // Kept in its own module (not inside gu-cli.mjs, which starts the daemon on import) so
10
+ // the classification is unit-testable.
11
+
12
+ /** How often to re-log an UNCHANGED failure while it persists. */
13
+ export const REPEAT_MS = 60_000;
14
+
15
+ /**
16
+ * Classify a poll failure. Pure — takes the error, returns what to say about it.
17
+ * `key` identifies the KIND of failure, so repeats can be collapsed;
18
+ * `line` is the one-sentence description; `hint` is what the user can do about it;
19
+ * `unexpected` marks errors we have no explanation for (those keep their stack trace).
20
+ */
21
+ export function describePollError(err, { apiBase = "the API" } = {}) {
22
+ const status = Number(err?.status) || 0;
23
+ const body = String(err?.body ?? "");
24
+ const msg = String(err?.message ?? err ?? "");
25
+
26
+ // The API reached its auth middleware but the worker_keys lookup THREW — i.e. the API is
27
+ // up and the key never even got checked. Worth its own message: the natural reading of
28
+ // "worker key ... failed" is "my key is bad", and that is exactly what it is NOT.
29
+ if (/worker key lookup failed/i.test(body)) {
30
+ return {
31
+ key: "api-db-down",
32
+ line: `${apiBase} is up but cannot reach its database, so it can't verify ANY worker key.`,
33
+ hint:
34
+ "Nothing is wrong with this machine or your key — check the API's MongoDB " +
35
+ "(cluster paused? IP allow-list? rotated credentials?) and the API logs. Retrying.",
36
+ };
37
+ }
38
+ if (status === 401) {
39
+ return {
40
+ key: "auth-missing",
41
+ line: "this worker isn't sending a key, so the API rejected it.",
42
+ hint: "Run `gu-cli login` (or `gu-cli set <workerKey>`) to configure this machine.",
43
+ };
44
+ }
45
+ if (status === 403) {
46
+ return {
47
+ key: "auth-invalid",
48
+ line: "the API rejected this worker key — it isn't registered to any account.",
49
+ hint: "The key was probably rotated or the account reset. Run `gu-cli login` to pair this machine again.",
50
+ };
51
+ }
52
+ if (status === 404) {
53
+ return {
54
+ key: "api-404",
55
+ line: `${apiBase} has no /api/worker/jobs/next endpoint (404).`,
56
+ hint: "The API base URL may point at the wrong deployment — check GONEXT_API_BASE in ~/.gonext/worker.env.",
57
+ };
58
+ }
59
+ if (status >= 500) {
60
+ return {
61
+ key: `api-${status}`,
62
+ line: `${apiBase} returned ${status}.`,
63
+ hint: `Server-side problem, not this machine. Retrying.${body ? ` API said: ${trim(body)}` : ""}`,
64
+ };
65
+ }
66
+ if (status) {
67
+ return {
68
+ key: `http-${status}`,
69
+ line: `${apiBase} returned ${status}.`,
70
+ hint: body ? `API said: ${trim(body)}` : "",
71
+ };
72
+ }
73
+ // No HTTP status ⇒ the request never completed: DNS, offline, TLS, timeout.
74
+ if (/fetch failed|ECONN|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|socket hang up|network|terminated|abort/i.test(msg)) {
75
+ return {
76
+ key: "network",
77
+ line: `can't reach ${apiBase}.`,
78
+ hint: "Check your internet connection and that the API base URL is right. Retrying.",
79
+ };
80
+ }
81
+ return { key: `unexpected:${msg.slice(0, 60)}`, line: msg || String(err), hint: "", unexpected: true };
82
+ }
83
+
84
+ function trim(s) {
85
+ const one = String(s).replace(/\s+/g, " ").trim();
86
+ return one.length > 160 ? `${one.slice(0, 159)}…` : one;
87
+ }
88
+
89
+ /**
90
+ * Stateful reporter: prints the first occurrence in full, then stays quiet until the same
91
+ * failure has been going for REPEAT_MS, and announces recovery with how long it was down.
92
+ * `log`/`err` are injectable so tests can capture the output.
93
+ */
94
+ export function createPollErrorReporter({
95
+ apiBase = "the API",
96
+ log = console.log,
97
+ err = console.error,
98
+ now = () => Date.now(),
99
+ repeatMs = REPEAT_MS,
100
+ } = {}) {
101
+ let key = "";
102
+ let streak = 0;
103
+ let since = 0;
104
+ let loggedAt = 0;
105
+ const tag = "[gu-worker]";
106
+
107
+ return {
108
+ /** Report a failed poll. */
109
+ fail(e) {
110
+ const d = describePollError(e, { apiBase });
111
+ const t = now();
112
+ if (d.key === key) {
113
+ streak += 1;
114
+ if (t - loggedAt < repeatMs) return; // same failure, already explained
115
+ loggedAt = t;
116
+ err(`${tag} still failing — ${d.line} (${streak}× over ${Math.round((t - since) / 1000)}s)`);
117
+ return;
118
+ }
119
+ key = d.key;
120
+ streak = 1;
121
+ since = t;
122
+ loggedAt = t;
123
+ err(`${tag} ⚠ ${d.line}`);
124
+ if (d.hint) err(`${tag} ${d.hint}`);
125
+ // Only something we can't explain is worth a stack trace.
126
+ if (d.unexpected) err(e);
127
+ },
128
+ /** Report a successful poll — announces recovery only if we were failing. */
129
+ ok() {
130
+ if (!key) return;
131
+ const secs = Math.round((now() - since) / 1000);
132
+ log(`${tag} ✓ recovered — polling again after ${streak} failure(s) over ${secs}s.`);
133
+ key = "";
134
+ streak = 0;
135
+ },
136
+ /** Current failure key ("" when healthy) — exposed for tests/diagnostics. */
137
+ get failingSince() {
138
+ return key ? since : 0;
139
+ },
140
+ };
141
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * `/proxy` — the whole lifecycle, as decisions rather than IO (task #160).
3
+ *
4
+ * SEPARATE FROM THE REPL BECAUSE THE REPL IS UNTESTABLE. Everything here is a pure function of
5
+ * its arguments: what the user typed, what is stored, what the network answered. The REPL keeps
6
+ * only the parts that genuinely need a terminal — asking, masking the password, printing. That
7
+ * split is what lets the interesting cases (a wrong password, a proxy that is up but blocks the
8
+ * model host, a machine with no models configured yet) be tested without any of them being true.
9
+ *
10
+ * A PROXY THAT CAN ONLY BE ADDED IS USELESS the first time a password rotates, so every verb
11
+ * exists: show, set, on, off, clear. `off` deliberately keeps the credentials — a proxy that has
12
+ * to be retyped whenever it is switched off is one people leave on, which is exactly the state
13
+ * that breaks a laptop the moment it leaves the office.
14
+ */
15
+ import {
16
+ normalizeProxy,
17
+ isUsableProxy,
18
+ describeProxy,
19
+ modelOrigins,
20
+ classifyProxyFailure,
21
+ proxyFailureAdvice,
22
+ } from "./proxy-config.mjs";
23
+
24
+ /**
25
+ * What did the user mean?
26
+ *
27
+ * A bare `/proxy` SHOWS rather than starting an interrogation: the commonest reason to type it
28
+ * is to find out what is currently set, and a command that immediately demands a hostname is one
29
+ * people stop typing. Setting is an explicit verb.
30
+ */
31
+ export function parseProxyCommand(line) {
32
+ const parts = String(line ?? "").trim().split(/\s+/).filter(Boolean);
33
+ if (parts[0] !== "/proxy") return { action: "none" };
34
+ const verb = (parts[1] ?? "").toLowerCase();
35
+ if (!verb || verb === "show" || verb === "status") return { action: "show" };
36
+ if (verb === "set" || verb === "add" || verb === "edit") return { action: "set" };
37
+ if (verb === "on" || verb === "enable") return { action: "on" };
38
+ if (verb === "off" || verb === "disable") return { action: "off" };
39
+ if (verb === "clear" || verb === "remove" || verb === "forget") return { action: "clear" };
40
+ if (verb === "test" || verb === "check") return { action: "test" };
41
+ return { action: "unknown", verb };
42
+ }
43
+
44
+ /** The one-line help shown for an unrecognised verb — every verb, so nothing is undiscoverable. */
45
+ export const PROXY_USAGE =
46
+ "/proxy [show|set|on|off|clear|test] — show is the default";
47
+
48
+ /**
49
+ * Is this a usable host/port before anything is stored?
50
+ *
51
+ * Rejecting at the prompt matters more than it looks: a bad port stored now fails much later, in
52
+ * the middle of a turn, as a connection error that names neither the proxy nor the typo.
53
+ * Returns "" when fine, else the reason.
54
+ */
55
+ export function validateHostPort({ host, port }) {
56
+ const h = String(host ?? "").trim();
57
+ if (!h) return "a proxy needs a host";
58
+ if (/^https?:\/\//i.test(h)) return "just the host, without http:// — the scheme is asked separately";
59
+ if (/\s/.test(h)) return "a host cannot contain spaces";
60
+ if (h.includes("@")) return "credentials go in the username/password prompts, not the host";
61
+ const p = Number.parseInt(String(port ?? "").trim(), 10);
62
+ if (!Number.isInteger(p) || p <= 0 || p > 65535) return "the port must be a number between 1 and 65535";
63
+ return "";
64
+ }
65
+
66
+ /**
67
+ * What `/proxy` prints when asked to show itself.
68
+ *
69
+ * Plain strings, no colour — the caller owns presentation, and a test that asserts on ANSI is
70
+ * asserting on the wrong layer. It names WHICH endpoints are covered, because the scope is an
71
+ * allowlist and "the proxy is on" is not enough to predict what happens: someone debugging a
72
+ * blocked `npm install` needs to see that this setting has nothing to do with it.
73
+ */
74
+ export function proxySummary(proxy, settings) {
75
+ const p = normalizeProxy(proxy);
76
+ const lines = [];
77
+ if (!p.host) {
78
+ lines.push("no proxy configured — model calls go out directly");
79
+ lines.push("`/proxy set` to add one");
80
+ return lines;
81
+ }
82
+ lines.push(`proxy ${describeProxy(p)}`);
83
+ const origins = modelOrigins(settings).filter((o) => !/^https?:\/\/(localhost|127\.|\[::1\])/.test(o));
84
+ if (!isUsableProxy(p)) {
85
+ lines.push(
86
+ p.enabled
87
+ ? "incomplete — it has no usable host/port, so nothing is being routed"
88
+ : "disabled — model calls go out directly (`/proxy on` to use it again)"
89
+ );
90
+ } else if (origins.length === 0) {
91
+ // Enabled, complete, and doing nothing — worth saying, or it looks broken later.
92
+ lines.push("enabled, but no REMOTE model is configured, so there is nothing to route");
93
+ } else {
94
+ lines.push(`routing ${origins.length} model endpoint(s): ${origins.join(", ")}`);
95
+ }
96
+ lines.push("local calls (127.0.0.1, localhost) always go direct");
97
+ return lines;
98
+ }
99
+
100
+ /** The startup-banner row, or "" when there is nothing worth a line. Host only — never the password. */
101
+ export function bannerProxyRow(proxy) {
102
+ const p = normalizeProxy(proxy);
103
+ if (!isUsableProxy(p)) return "";
104
+ return `${p.host}:${p.port}${p.username ? ` (as ${p.username})` : ""} · model calls only`;
105
+ }
106
+
107
+ /**
108
+ * Reach a configured model endpoint THROUGH the proxy and report what happened.
109
+ *
110
+ * AT THE PROMPT, NOT AT FIRST USE. Credentials that are merely stored fail later, inside a turn,
111
+ * as a network error the model cannot act on and the user cannot read. Failing here — while they
112
+ * still have the password in their head — is the difference between a typo and an outage.
113
+ *
114
+ * The verdicts are separated because they imply DIFFERENT fixes: retype the password, check the
115
+ * proxy is up, or talk to whoever runs it about the model host. A single "network error" sends
116
+ * the user nowhere.
117
+ */
118
+ export async function verifyProxy({ proxy, settings, fetchImpl, timeoutMs = 15000 } = {}) {
119
+ const p = normalizeProxy(proxy);
120
+ if (!isUsableProxy(p)) {
121
+ return { ok: false, kind: "not-configured", detail: "no usable proxy to test" };
122
+ }
123
+ const target = modelOrigins(settings).find(
124
+ (o) => !/^https?:\/\/(localhost|127\.|0\.0\.0\.0|\[::1\])/.test(o)
125
+ );
126
+ if (!target) {
127
+ // Not a failure OF the proxy — there is simply nothing it would carry. Saying "ok" here
128
+ // would be a lie, and saying "failed" would send someone hunting a working proxy.
129
+ return {
130
+ ok: false,
131
+ kind: "no-models",
132
+ detail: "no remote model is configured, so there is nothing to test the proxy against",
133
+ };
134
+ }
135
+
136
+ const controller = new AbortController();
137
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
138
+ try {
139
+ // GET the endpoint root: every OpenAI-compatible server answers SOMETHING there, and we only
140
+ // care whether bytes crossed the proxy — a 404 from the model host is a complete success for
141
+ // this question, which is why the classifier treats any 2xx/3xx/4xx-that-is-not-407 as ok.
142
+ const res = await fetchImpl(
143
+ `${target}/v1/models`,
144
+ { method: "GET", signal: controller.signal },
145
+ { proxy: p, settings }
146
+ );
147
+ const kind = res.status === 407 ? "proxy-auth" : classifyProxyFailure({ status: res.status });
148
+ // A 404 or 401 FROM THE MODEL HOST means the tunnel worked — that is what is being tested.
149
+ const reached = res.status !== 407 && !(res.status === 403 || res.status === 502 || res.status === 503);
150
+ return {
151
+ ok: reached,
152
+ kind: reached ? "ok" : kind,
153
+ target,
154
+ status: res.status,
155
+ detail: reached
156
+ ? `reached ${target} through the proxy (HTTP ${res.status})`
157
+ : proxyFailureAdvice(kind, p),
158
+ };
159
+ } catch (err) {
160
+ const kind = classifyProxyFailure({ code: err?.code || "", message: err?.message || "" });
161
+ return { ok: false, kind, target, detail: proxyFailureAdvice(kind, p) };
162
+ } finally {
163
+ clearTimeout(timer);
164
+ }
165
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * The outbound proxy: which calls go through it, how it is addressed, and how it is described
3
+ * without leaking the password (task #160).
4
+ *
5
+ * WHY A PROXY AT ALL. Client Expert moves the API and the database onto the user's machine but
6
+ * not the models — the banner says "client expert (API + database here · models remote)". So the
7
+ * only traffic still leaving the machine is exactly what a corporate network blocks: the three
8
+ * configured models. Without this, Client Expert cannot run on such a network, and it fails
9
+ * looking like a dead model rather than a blocked one.
10
+ *
11
+ * AN ALLOWLIST, NOT A BYPASS LIST — the single most important decision here (user, 2026-08-09:
12
+ * "only proxy if proxy is enable and after that proxy all the request to agent model and agent
13
+ * code model", then "yes, proxy the embed model too, so 3 models"). The obvious build proxies
14
+ * everything and subtracts loopback; this one proxies only the three configured model URLs and
15
+ * leaves everything else alone. That is safer by construction: a URL nobody thought about goes
16
+ * direct, which is today's behaviour, so the worst case of a mistake here is "the proxy did not
17
+ * apply" rather than "the terminal stopped working".
18
+ *
19
+ * THE DECISION FOLLOWS THE URL, NOT THE ROLE AND NOT THE MODE. The same code path serves a local
20
+ * and a remote model; only the configured host differs. So nothing here asks "is this the embed
21
+ * model" or "are we in Client Expert" — it asks "is this resolved URL one of the configured
22
+ * model endpoints, and is it actually remote". A user who points the embed model back at a local
23
+ * Ollama stops being proxied automatically, and a hosted-mode user behind a corporate proxy is
24
+ * covered without a mode change (#154's "configuration, not a second code path").
25
+ *
26
+ * PURE. No sockets, no dispatcher, no environment. The transports that consume these decisions
27
+ * live at the call sites — an httpx/OpenAI transport in Python, an undici dispatcher in Node —
28
+ * because those differ per stack while the decision must not.
29
+ */
30
+
31
+ /**
32
+ * A proxy the user has configured. `enabled` is separate from "is it filled in" on purpose:
33
+ * turning the proxy off must not throw the credentials away, or every toggle costs a retype.
34
+ */
35
+ export function normalizeProxy(raw) {
36
+ // `raw = {}` as a default parameter is NOT enough: a default fills only `undefined`, so an
37
+ // explicit null — which is exactly what "no proxy configured" looks like coming out of a
38
+ // loader or a settings row — reached the property reads and threw. That crash would take down
39
+ // the model call it was meant to leave alone, on the machines least involved with proxies.
40
+ const input = raw || {};
41
+ const host = String(input.host ?? "").trim();
42
+ const rawPort = input.port;
43
+ const port = Number.parseInt(String(rawPort ?? "").trim(), 10);
44
+ return {
45
+ enabled: input.enabled === true,
46
+ host,
47
+ // 0 is not a usable port and NaN is not a value worth carrying forward; both become null so
48
+ // isUsableProxy() has one thing to check rather than three.
49
+ port: Number.isInteger(port) && port > 0 && port <= 65535 ? port : null,
50
+ username: String(input.username ?? "").trim(),
51
+ password: String(input.password ?? ""),
52
+ // http by default: a corporate proxy is almost always plain HTTP even when the traffic it
53
+ // carries is HTTPS (the client sends CONNECT). Assuming https here would fail on most.
54
+ protocol: input.protocol === "https" ? "https" : "http",
55
+ };
56
+ }
57
+
58
+ /** Enabled AND complete enough to dial. An enabled proxy with no host must not silently do nothing. */
59
+ export function isUsableProxy(proxy) {
60
+ const p = normalizeProxy(proxy);
61
+ return p.enabled && p.host.length > 0 && p.port !== null;
62
+ }
63
+
64
+ /**
65
+ * The dial string for the transport.
66
+ *
67
+ * Credentials are percent-encoded because a password containing @ : / or # would otherwise
68
+ * reshape the URL — a password ending in "@" would make the parser read the wrong host, which
69
+ * fails as a DNS error and sends the user looking in the wrong place entirely.
70
+ *
71
+ * Returns "" when the proxy is not usable, so a caller that forgets to check gets no proxy
72
+ * rather than a malformed one.
73
+ */
74
+ export function proxyUrl(proxy) {
75
+ const p = normalizeProxy(proxy);
76
+ if (!isUsableProxy(p)) return "";
77
+ const auth = p.username
78
+ ? `${encodeURIComponent(p.username)}:${encodeURIComponent(p.password)}@`
79
+ : "";
80
+ return `${p.protocol}://${auth}${p.host}:${p.port}`;
81
+ }
82
+
83
+ /**
84
+ * The whole 127.0.0.0/8 range, as an ADDRESS rather than a prefix.
85
+ *
86
+ * `/^127\./` was the first version and it is wrong in a way worth remembering: it matches the
87
+ * HOSTNAME "127.0.0.1.evil.com", which is a perfectly ordinary remote domain. Anyone who can
88
+ * name a host that way could opt themselves out of the proxy, and more mundanely a real host in
89
+ * a nip.io-style scheme would silently skip it. Each octet is range-checked so "127.999.0.1" —
90
+ * not an address — is not treated as one either.
91
+ */
92
+ const IPV4_LOOPBACK = /^127(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
93
+
94
+ /** Loopback: never remote, so never proxied even if a model is configured there. */
95
+ export function isLoopbackUrl(url) {
96
+ try {
97
+ let h = new URL(url).hostname.toLowerCase().replace(/^\[|\]$/g, "");
98
+ // IPv4-mapped IPv6 (::ffff:127.0.0.1) is the same machine by another spelling, and undici
99
+ // will happily connect to it — so it has to be recognised, not just look unfamiliar.
100
+ //
101
+ // AND IT ARRIVES IN HEX. `new URL("http://[::ffff:127.0.0.1]/")` normalises the hostname to
102
+ // "[::ffff:7f00:1]", so a string comparison against the dotted form matches nothing — the
103
+ // two 16-bit groups have to be expanded back into an address before the range test.
104
+ const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(h);
105
+ if (mapped) {
106
+ const n = (parseInt(mapped[1], 16) << 16) | parseInt(mapped[2], 16);
107
+ h = [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join(".");
108
+ } else {
109
+ h = h.replace(/^::ffff:/, "");
110
+ }
111
+ return (
112
+ h === "localhost" ||
113
+ h === "::1" ||
114
+ h === "0.0.0.0" ||
115
+ h === "::" ||
116
+ // A real subdomain of the reserved .localhost TLD (RFC 6761), not merely a name ENDING in
117
+ // the text — "localhost.evil.com" is remote and must stay proxied.
118
+ h.endsWith(".localhost") ||
119
+ IPV4_LOOPBACK.test(h)
120
+ );
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * The origin (scheme + host + port) of a URL, which is what "the same endpoint" means here.
128
+ *
129
+ * A configured model is https://ollama1.gomarsic.cc/v1 while the actual call is
130
+ * https://ollama1.gomarsic.cc/v1/chat/completions — comparing full URLs would match neither, and
131
+ * comparing hostnames alone would proxy a DIFFERENT service that happens to share the host.
132
+ * Origin is the level that means "this endpoint", and URL normalizes the default port for us.
133
+ */
134
+ export function originOf(url) {
135
+ try {
136
+ return new URL(url).origin.toLowerCase();
137
+ } catch {
138
+ return "";
139
+ }
140
+ }
141
+
142
+ /**
143
+ * The three model endpoints, read from the settings row rather than hardcoded — the user can
144
+ * change any of them at any time (/model, the web app), and a hardcoded ollama1.gomarsic.cc
145
+ * would keep proxying an endpoint they have already moved away from.
146
+ */
147
+ export function modelOrigins(settings = {}) {
148
+ const s = settings || {};
149
+ const urls = [
150
+ s.agentModelUrl,
151
+ s.agentCodingModelUrl,
152
+ s.ragEmbedUrl,
153
+ // THE FALLBACKS COUNT TOO. resolveConfiguredModels() (model-doctor.mjs) uses agentModelUrl
154
+ // when set and otherwise falls back to the account's MLX base URL — so on a machine that
155
+ // configures the agent that way, reading only the three explicit fields would leave the
156
+ // agent model OFF the allowlist and unproxied, which on a restricted network is the exact
157
+ // failure this feature exists to prevent. The third fallback in that chain is a port on
158
+ // 127.0.0.1, which is loopback and excluded anyway, so it needs nothing here.
159
+ s.mlxOpenAiBaseUrl,
160
+ s.hosts?.[0]?.mlxOpenAiBaseUrl,
161
+ ];
162
+ return [...new Set(urls.map(originOf).filter(Boolean))];
163
+ }
164
+
165
+ /**
166
+ * THE DECISION. Does this specific request go through the proxy?
167
+ *
168
+ * Three conditions, and all of them are the point:
169
+ * · the proxy is enabled and usable — "only proxy if proxy is enable" is the first clause of
170
+ * the requirement and the easiest to lose by installing a dispatcher and forgetting it;
171
+ * · the URL is one of the configured model endpoints — the allowlist;
172
+ * · the URL is not loopback — a locally served model needs no proxy, and the local API at
173
+ * 127.0.0.1:4100 must never be routed through a corporate proxy, which would break every
174
+ * turn while looking like the proxy is down.
175
+ */
176
+ export function shouldProxy(url, { proxy, settings } = {}) {
177
+ if (!isUsableProxy(proxy)) return false;
178
+ if (isLoopbackUrl(url)) return false;
179
+ // No guard against an empty origin here, deliberately: modelOrigins() drops unparseable URLs,
180
+ // so "" can never be IN the allowlist and an unparseable request URL therefore cannot match.
181
+ // An extra `origin !== ""` check reads as prudent but is unreachable — a branch no test can
182
+ // reach is a branch nobody can trust, so the invariant is pinned in modelOrigins instead
183
+ // ("a malformed configured URL is ignored rather than matching everything").
184
+ return modelOrigins(settings).includes(originOf(url));
185
+ }
186
+
187
+ /**
188
+ * The proxy as it may be shown to a human — host, port, and WHETHER it authenticates, never the
189
+ * password and never a credential-bearing URL.
190
+ *
191
+ * Formatting is centralised here rather than trusting each call site to remember, because the
192
+ * leak this prevents is the easy one to ship: proxyUrl() embeds user:pass@, and that string
193
+ * otherwise reaches exception text, `gu status`, a doctor dump and the worker log verbatim.
194
+ */
195
+ export function describeProxy(proxy) {
196
+ const p = normalizeProxy(proxy);
197
+ if (!p.host) return "not configured";
198
+ const where = `${p.host}${p.port ? `:${p.port}` : ""}`;
199
+ const auth = p.username ? ` (as ${p.username})` : "";
200
+ return `${p.enabled ? "" : "disabled — "}${where}${auth}`;
201
+ }
202
+
203
+ /**
204
+ * Strip credentials out of any URL inside an arbitrary string.
205
+ *
206
+ * A LAST LINE OF DEFENCE, not the primary one: exception text from a transport routinely
207
+ * contains the dial URL, and that text flows into logs and into the model's context. Applied to
208
+ * anything about to be printed or stored. The password is replaced rather than removed so the
209
+ * reader can still see that credentials WERE configured — "the proxy has no password" and "the
210
+ * password was redacted" are different diagnoses.
211
+ */
212
+ export function redactSecrets(text) {
213
+ return String(text ?? "").replace(
214
+ /\b([a-z][a-z0-9+.-]*:\/\/)([^/\s:@]+)(?::([^/\s@]*))?@/gi,
215
+ (_m, scheme, user, pass) => `${scheme}${user}${pass === undefined ? "" : ":***"}@`
216
+ );
217
+ }
218
+
219
+ /**
220
+ * What went wrong, in terms that imply different fixes.
221
+ *
222
+ * The point of separating these is that "retype your password", "the proxy is down" and "the
223
+ * proxy works but will not let you reach this host" send the user to three different places, and
224
+ * a single "network error" sends them nowhere. 407 in particular is unambiguous and must never
225
+ * be reported as the model being offline — which is how it looks if this is skipped.
226
+ */
227
+ export function classifyProxyFailure({ status = 0, code = "", message = "" } = {}) {
228
+ const text = `${code} ${message}`.toLowerCase();
229
+ if (status === 407) return "proxy-auth";
230
+ // A CONNECT refused BY the proxy is the proxy talking; a refused connection TO the proxy is the
231
+ // proxy being down. Both surface as ECONNREFUSED, so the status code is what separates them.
232
+ if (status === 403 || status === 502 || status === 503) return "destination-blocked";
233
+ if (/econnrefused|ehostunreach|enetunreach|enotfound|eai_again|timeout|etimedout/.test(text)) {
234
+ return "proxy-unreachable";
235
+ }
236
+ if (status >= 200 && status < 400) return "ok";
237
+ return "unknown";
238
+ }
239
+
240
+ /** One line the user can act on, per failure kind. A diagnosis without a next move is just noise. */
241
+ export function proxyFailureAdvice(kind, proxy) {
242
+ const where = describeProxy(proxy);
243
+ switch (kind) {
244
+ case "proxy-auth":
245
+ return `the proxy rejected the credentials (407) — check the username and password with \`/proxy\` (${where})`;
246
+ case "proxy-unreachable":
247
+ return `could not reach the proxy itself at ${where} — check the host and port, and that it is running`;
248
+ case "destination-blocked":
249
+ return `the proxy is reachable but refused to connect to the model endpoint — the host may not be allowed through ${where}`;
250
+ case "ok":
251
+ return `the proxy works — model calls will go through ${where}`;
252
+ default:
253
+ return `the proxy check failed for an unrecognised reason (${where})`;
254
+ }
255
+ }