@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.
- package/README.md +52 -0
- package/agent-model-command.mjs +259 -0
- package/agent-model-label.mjs +159 -0
- package/clear-state.mjs +149 -0
- package/client-expert-api.mjs +736 -0
- package/client-expert-run.mjs +892 -0
- package/client-expert-setup.mjs +616 -0
- package/coding-choice-tags.mjs +69 -0
- package/coding-key-prompt.mjs +229 -0
- package/coding-provider-setup.mjs +808 -0
- package/completed-flush.mjs +105 -0
- package/daemon-control.mjs +462 -0
- package/device-login.mjs +212 -0
- package/doctor-check.mjs +239 -0
- package/embed-model-command.mjs +157 -0
- package/first-run-steps.mjs +171 -0
- package/gonext_agent_chat.py +12299 -0
- package/gonext_mlx_embed.py +155 -0
- package/gonext_probe_agent.py +93 -0
- package/gonext_transcribe.py +130 -0
- package/gu-cli.mjs +4930 -0
- package/gu-repl.mjs +10326 -0
- package/job-pools.mjs +89 -0
- package/model-doctor.mjs +1494 -0
- package/node-version.mjs +40 -0
- package/ollama-setup.mjs +832 -0
- package/package.json +100 -0
- package/platform-tools.mjs +520 -0
- package/poll-errors.mjs +141 -0
- package/proxy-command.mjs +165 -0
- package/proxy-config.mjs +255 -0
- package/proxy-dispatcher.mjs +132 -0
- package/proxy-selftest.mjs +234 -0
- package/proxy-store.mjs +69 -0
- package/rag-job-config.mjs +59 -0
- package/rag-selftest.mjs +215 -0
- package/s3-setup.mjs +85 -0
- package/terminal-copy.mjs +248 -0
- package/terminal-hover.mjs +153 -0
- package/terminal-layout.mjs +2507 -0
- package/terminal-viewport.mjs +602 -0
- package/thinking_words.txt +1003 -0
- package/version-check.mjs +72 -0
package/poll-errors.mjs
ADDED
|
@@ -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
|
+
}
|
package/proxy-config.mjs
ADDED
|
@@ -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
|
+
}
|