@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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Node half of the proxy: turning a decision into an actual routed request (task #160).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS NOT JUST "SET HTTPS_PROXY". Node's global fetch is undici, and undici does NOT
|
|
5
|
+
* read HTTP_PROXY/HTTPS_PROXY — setting them changes nothing, silently. (Node 24 added
|
|
6
|
+
* NODE_USE_ENV_PROXY, but engines.node is >=18 here and the dev machine is on 22, so the
|
|
7
|
+
* dependency is the portable answer.) This is the trap most likely to produce a build where the
|
|
8
|
+
* Python agent works and every Node-side probe reports the model down.
|
|
9
|
+
*
|
|
10
|
+
* PER-REQUEST, NEVER GLOBAL. setGlobalDispatcher() would route EVERY fetch in the process
|
|
11
|
+
* through the proxy — including the terminal's constant polling of http://127.0.0.1:4100 — which
|
|
12
|
+
* is precisely the failure the allowlist exists to prevent, and it would survive turning the
|
|
13
|
+
* proxy off within a session. So the dispatcher is attached to the individual request, and only
|
|
14
|
+
* when shouldProxy() says so.
|
|
15
|
+
*/
|
|
16
|
+
import { shouldProxy, proxyUrl, redactSecrets } from "./proxy-config.mjs";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One ProxyAgent per dial string, kept for reuse.
|
|
20
|
+
*
|
|
21
|
+
* Not an optimisation — a new agent per request opens a new connection pool per request, and a
|
|
22
|
+
* proxy that authenticates then re-authenticates on every single call, which is both slow and a
|
|
23
|
+
* good way to trip an account lockout policy. Keyed by the dial string so changing the password
|
|
24
|
+
* produces a new agent rather than silently reusing the old credentials.
|
|
25
|
+
*/
|
|
26
|
+
const agents = new Map();
|
|
27
|
+
|
|
28
|
+
let ProxyAgentCtor;
|
|
29
|
+
let loadError = "";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* undici, loaded lazily and tolerantly.
|
|
33
|
+
*
|
|
34
|
+
* A machine with no proxy configured must not fail to start because an optional dependency did
|
|
35
|
+
* not install — the whole feature is off by default, and "gu will not run" is a far worse
|
|
36
|
+
* outcome than "the proxy is unavailable". So a missing undici is reported only to someone who
|
|
37
|
+
* actually asked for a proxy.
|
|
38
|
+
*/
|
|
39
|
+
async function proxyAgentCtor() {
|
|
40
|
+
if (ProxyAgentCtor || loadError) return ProxyAgentCtor;
|
|
41
|
+
try {
|
|
42
|
+
({ ProxyAgent: ProxyAgentCtor } = await import("undici"));
|
|
43
|
+
} catch (err) {
|
|
44
|
+
loadError = err?.message || String(err);
|
|
45
|
+
}
|
|
46
|
+
return ProxyAgentCtor;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The reusable agent for this proxy, or null if undici is unavailable. */
|
|
50
|
+
export async function dispatcherFor(proxy) {
|
|
51
|
+
const url = proxyUrl(proxy);
|
|
52
|
+
if (!url) return null;
|
|
53
|
+
if (agents.has(url)) return agents.get(url);
|
|
54
|
+
const Ctor = await proxyAgentCtor();
|
|
55
|
+
if (!Ctor) return null;
|
|
56
|
+
const agent = new Ctor(url);
|
|
57
|
+
agents.set(url, agent);
|
|
58
|
+
return agent;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Has the undici package actually been LOADED in this process?
|
|
63
|
+
*
|
|
64
|
+
* Exists for the tests, and the property it pins is one the user asked for directly: a machine
|
|
65
|
+
* with no proxy must not merely avoid ROUTING through one — it must never touch the proxy stack
|
|
66
|
+
* at all. Import cost, an extra connection pool, and any bug in that code path are all things
|
|
67
|
+
* the overwhelming majority of users (who have no proxy) should never be exposed to. Nothing in
|
|
68
|
+
* the product reads this; a test is the only way to prove a negative like "it was not loaded".
|
|
69
|
+
*/
|
|
70
|
+
export function undiciLoaded() {
|
|
71
|
+
return Boolean(ProxyAgentCtor);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** How many proxy agents exist. Also for the tests: zero means nothing was ever dialled. */
|
|
75
|
+
export function dispatcherCount() {
|
|
76
|
+
return agents.size;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Why a proxy could not be used, in terms someone can act on. "" when there is no problem. */
|
|
80
|
+
export function proxyUnavailableReason() {
|
|
81
|
+
return loadError
|
|
82
|
+
? `the proxy needs the "undici" package, which is not installed here (${loadError}) — ` +
|
|
83
|
+
`reinstall gu, or run without a proxy`
|
|
84
|
+
: "";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* fetch(), routed through the proxy when — and only when — this URL is one of the configured
|
|
89
|
+
* model endpoints and the proxy is enabled.
|
|
90
|
+
*
|
|
91
|
+
* A DROP-IN FOR fetch AT THE MODEL CALL SITES. Anything not in the allowlist gets a plain fetch
|
|
92
|
+
* with no dispatcher, byte for byte the behaviour it has today, which is what makes adopting
|
|
93
|
+
* this at a call site safe even if that call site sometimes talks to something else.
|
|
94
|
+
*
|
|
95
|
+
* Errors are re-thrown with the credentials stripped: undici puts the dial URL into connection
|
|
96
|
+
* error messages, and that text ends up in the worker log and in the model's context.
|
|
97
|
+
*/
|
|
98
|
+
export async function proxyFetch(url, init = {}, { proxy, settings } = {}) {
|
|
99
|
+
if (!shouldProxy(url, { proxy, settings })) return fetch(url, init);
|
|
100
|
+
const dispatcher = await dispatcherFor(proxy);
|
|
101
|
+
// No dispatcher means undici is missing. Going DIRECT is the honest fallback: on a network
|
|
102
|
+
// that needs the proxy the call fails with a real network error, which is actionable, whereas
|
|
103
|
+
// throwing here would take down a code path that has nothing to do with proxies.
|
|
104
|
+
if (!dispatcher) return fetch(url, init);
|
|
105
|
+
try {
|
|
106
|
+
return await fetch(url, { ...init, dispatcher });
|
|
107
|
+
} catch (err) {
|
|
108
|
+
const clean = new Error(redactSecrets(err?.message || String(err)));
|
|
109
|
+
clean.cause = err?.cause;
|
|
110
|
+
clean.viaProxy = true;
|
|
111
|
+
throw clean;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Drop cached agents — called when the proxy changes or is disabled.
|
|
117
|
+
*
|
|
118
|
+
* Without this, disabling the proxy leaves a live connection pool to it: harmless for routing
|
|
119
|
+
* (shouldProxy is consulted per request), but it holds sockets open to a machine the user has
|
|
120
|
+
* said they are no longer using, and on a laptop that has left the office those sockets fail
|
|
121
|
+
* slowly rather than immediately.
|
|
122
|
+
*/
|
|
123
|
+
export function resetDispatchers() {
|
|
124
|
+
for (const agent of agents.values()) {
|
|
125
|
+
try {
|
|
126
|
+
agent.close?.();
|
|
127
|
+
} catch {
|
|
128
|
+
/* closing a pool must never be the thing that fails a command */
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
agents.clear();
|
|
132
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Does the proxy work on THIS machine?" — shipped, so it can be run where the problem is.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS SEPARATELY FROM THE TEST SUITE. tests/ is not published: an installed machine
|
|
5
|
+
* has gu-cli and nothing else. But a proxy is the one feature whose failures are entirely
|
|
6
|
+
* about the machine it runs on — a corporate network, a Windows box, an odd interception rule —
|
|
7
|
+
* so the checks have to be runnable exactly there. Same shape as rag-selftest.mjs, and for the
|
|
8
|
+
* same reason: `gu-cli rag --test` exists because RAG could only be diagnosed in place.
|
|
9
|
+
*
|
|
10
|
+
* NEEDS NOTHING AND CHANGES NOTHING. It starts its own proxy and its own origin server on
|
|
11
|
+
* loopback, checks them, and shuts them down. No network, no MongoDB, no Docker, no configuration
|
|
12
|
+
* — and it does not read or modify the user's own proxy settings, so running it on a working
|
|
13
|
+
* machine cannot break that machine. That makes it safe to tell anyone to run at any time.
|
|
14
|
+
*
|
|
15
|
+
* WINDOWS. Deliberately built from node:http and node:net alone: no shelling out, no ports
|
|
16
|
+
* assumed free (0 = let the OS choose), no POSIX signals, no path handling. The one platform
|
|
17
|
+
* question that remains is whether undici can dial a proxy at all, which is check 1.
|
|
18
|
+
*/
|
|
19
|
+
import net from "node:net";
|
|
20
|
+
import http from "node:http";
|
|
21
|
+
import { once } from "node:events";
|
|
22
|
+
import {
|
|
23
|
+
shouldProxy,
|
|
24
|
+
proxyUrl,
|
|
25
|
+
isLoopbackUrl,
|
|
26
|
+
redactSecrets,
|
|
27
|
+
classifyProxyFailure,
|
|
28
|
+
} from "./proxy-config.mjs";
|
|
29
|
+
import { proxyFetch, resetDispatchers, undiciLoaded } from "./proxy-dispatcher.mjs";
|
|
30
|
+
|
|
31
|
+
const USER = "selftest";
|
|
32
|
+
const PASS = "selftest-secret";
|
|
33
|
+
|
|
34
|
+
/** A real CONNECT proxy that records what it was asked for. The recording IS the test. */
|
|
35
|
+
function startProxy() {
|
|
36
|
+
const seen = [];
|
|
37
|
+
const authed = (req) => {
|
|
38
|
+
const [, b64] = String(req.headers["proxy-authorization"] || "").split(" ");
|
|
39
|
+
if (!b64) return false;
|
|
40
|
+
const [u, p] = Buffer.from(b64, "base64").toString().split(":");
|
|
41
|
+
return u === USER && p === PASS;
|
|
42
|
+
};
|
|
43
|
+
const server = http.createServer((req, res) => {
|
|
44
|
+
if (!authed(req)) {
|
|
45
|
+
res.writeHead(407, { "Proxy-Authenticate": 'Basic realm="gu"' });
|
|
46
|
+
res.end("proxy auth required");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
seen.push(req.url);
|
|
50
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
51
|
+
res.end(JSON.stringify({ viaProxy: true }));
|
|
52
|
+
});
|
|
53
|
+
server.on("connect", (req, clientSocket, head) => {
|
|
54
|
+
if (!authed(req)) {
|
|
55
|
+
clientSocket.end("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
seen.push(req.url);
|
|
59
|
+
// The PROXY resolves the name — which is why the checks below can use a hostname that does
|
|
60
|
+
// not exist in DNS. Reaching the origin then proves the tunnel carried the request.
|
|
61
|
+
const port = Number(req.url.split(":")[1]);
|
|
62
|
+
const upstream = net.connect(port, "127.0.0.1", () => {
|
|
63
|
+
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
64
|
+
upstream.write(head);
|
|
65
|
+
upstream.pipe(clientSocket);
|
|
66
|
+
clientSocket.pipe(upstream);
|
|
67
|
+
});
|
|
68
|
+
upstream.on("error", () => clientSocket.destroy());
|
|
69
|
+
clientSocket.on("error", () => upstream.destroy());
|
|
70
|
+
});
|
|
71
|
+
return { server, seen };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Stands in for a model endpoint, and records what actually arrived. */
|
|
75
|
+
function startOrigin() {
|
|
76
|
+
const hits = [];
|
|
77
|
+
const server = http.createServer((req, res) => {
|
|
78
|
+
hits.push(req.url);
|
|
79
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
80
|
+
res.end(JSON.stringify({ origin: true }));
|
|
81
|
+
});
|
|
82
|
+
return { server, hits };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const listen = async (server) => {
|
|
86
|
+
server.listen(0, "127.0.0.1");
|
|
87
|
+
await once(server, "listening");
|
|
88
|
+
return server.address().port;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Run the checks. Returns { ok, checks:[{title, ok, detail}] } — the same contract as
|
|
93
|
+
* runRagSelfTest, so the CLI, the npm script and the deploy-app can all render it identically.
|
|
94
|
+
*/
|
|
95
|
+
export async function runProxySelfTest({ onStep = () => {} } = {}) {
|
|
96
|
+
const checks = [];
|
|
97
|
+
const step = async (title, fn) => {
|
|
98
|
+
try {
|
|
99
|
+
const detail = await fn();
|
|
100
|
+
checks.push({ title, ok: true, detail: detail ?? "" });
|
|
101
|
+
onStep({ title, ok: true, detail: detail ?? "" });
|
|
102
|
+
} catch (e) {
|
|
103
|
+
const detail = redactSecrets(String(e?.message ?? e).split("\n")[0]);
|
|
104
|
+
checks.push({ title, ok: false, detail });
|
|
105
|
+
onStep({ title, ok: false, detail });
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const proxyFx = startProxy();
|
|
110
|
+
const originFx = startOrigin();
|
|
111
|
+
const proxyPort = await listen(proxyFx.server);
|
|
112
|
+
const originPort = await listen(originFx.server);
|
|
113
|
+
|
|
114
|
+
// The "model" is named by a hostname that does not resolve, on purpose: any request that
|
|
115
|
+
// reaches the origin can only have got there through the proxy.
|
|
116
|
+
const MODEL_URL = `http://model.invalid:${originPort}/v1`;
|
|
117
|
+
const settings = { agentModelUrl: MODEL_URL, agentCodingModelUrl: MODEL_URL, ragEmbedUrl: MODEL_URL };
|
|
118
|
+
const proxy = {
|
|
119
|
+
enabled: true,
|
|
120
|
+
host: "127.0.0.1",
|
|
121
|
+
port: proxyPort,
|
|
122
|
+
username: USER,
|
|
123
|
+
password: PASS,
|
|
124
|
+
};
|
|
125
|
+
const LOCAL_API = `http://127.0.0.1:${originPort}/api/worker/jobs/next`;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
await step("undici can be loaded", async () => {
|
|
129
|
+
const { dispatcherFor } = await import("./proxy-dispatcher.mjs");
|
|
130
|
+
const d = await dispatcherFor(proxy);
|
|
131
|
+
if (!d) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
'the "undici" package is missing — reinstall gu (npm i -g @tiens.nguyen/gu-cli)'
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return "the proxy transport is available";
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await step("a model call goes THROUGH the proxy", async () => {
|
|
140
|
+
proxyFx.seen.length = 0;
|
|
141
|
+
originFx.hits.length = 0;
|
|
142
|
+
const res = await proxyFetch(`${MODEL_URL}/chat/completions`, {}, { proxy, settings });
|
|
143
|
+
if (!res.ok) throw new Error(`the proxied call returned HTTP ${res.status}`);
|
|
144
|
+
// Two witnesses: the proxy was asked to connect, and the request arrived at the far end.
|
|
145
|
+
if (proxyFx.seen.length === 0) throw new Error("the proxy never saw the request");
|
|
146
|
+
if (originFx.hits.length === 0) throw new Error("the request never reached the endpoint");
|
|
147
|
+
return `tunnelled to ${proxyFx.seen[0]}`;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await step("all three model roles are routed", async () => {
|
|
151
|
+
originFx.hits.length = 0;
|
|
152
|
+
for (const p of ["/chat/completions", "/completions", "/embeddings"]) {
|
|
153
|
+
await proxyFetch(`${MODEL_URL}${p}`, {}, { proxy, settings });
|
|
154
|
+
}
|
|
155
|
+
if (originFx.hits.length !== 3) {
|
|
156
|
+
throw new Error(`only ${originFx.hits.length} of 3 arrived — chat, code and embed differ`);
|
|
157
|
+
}
|
|
158
|
+
return "chat, code and embed all reached the endpoint";
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await step("local traffic is NOT proxied", async () => {
|
|
162
|
+
// The check that matters most on a real machine: if this fails, every turn breaks the
|
|
163
|
+
// moment a proxy is switched on, because the terminal's own API calls get routed.
|
|
164
|
+
proxyFx.seen.length = 0;
|
|
165
|
+
const res = await proxyFetch(LOCAL_API, {}, { proxy, settings });
|
|
166
|
+
if (!res.ok) throw new Error(`the local call returned HTTP ${res.status}`);
|
|
167
|
+
if (proxyFx.seen.length > 0) {
|
|
168
|
+
throw new Error(`the local API was sent through the proxy (${proxyFx.seen[0]})`);
|
|
169
|
+
}
|
|
170
|
+
if (!isLoopbackUrl(LOCAL_API)) throw new Error("loopback was not recognised as local");
|
|
171
|
+
return "127.0.0.1 and localhost go direct";
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await step("a disabled proxy routes nothing", async () => {
|
|
175
|
+
proxyFx.seen.length = 0;
|
|
176
|
+
const off = { ...proxy, enabled: false };
|
|
177
|
+
if (shouldProxy(`${MODEL_URL}/chat/completions`, { proxy: off, settings })) {
|
|
178
|
+
throw new Error("a disabled proxy was still selected for a model call");
|
|
179
|
+
}
|
|
180
|
+
// And the call genuinely goes out on its own — which is why it must FAIL to resolve.
|
|
181
|
+
let wentDirect = false;
|
|
182
|
+
try {
|
|
183
|
+
await proxyFetch(`${MODEL_URL}/chat/completions`, {}, { proxy: off, settings });
|
|
184
|
+
} catch {
|
|
185
|
+
wentDirect = true;
|
|
186
|
+
}
|
|
187
|
+
if (!wentDirect || proxyFx.seen.length > 0) {
|
|
188
|
+
throw new Error("a disabled proxy still carried the request");
|
|
189
|
+
}
|
|
190
|
+
return "off means off";
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await step("bad credentials are reported as an auth failure", async () => {
|
|
194
|
+
// So a wrong password reads as "retype it", not as the model being down.
|
|
195
|
+
const bad = { ...proxy, password: "definitely-wrong" };
|
|
196
|
+
let status = 0;
|
|
197
|
+
try {
|
|
198
|
+
status = (await proxyFetch(`${MODEL_URL}/chat/completions`, {}, { proxy: bad, settings })).status;
|
|
199
|
+
} catch (e) {
|
|
200
|
+
if (String(e.message).includes("definitely-wrong")) {
|
|
201
|
+
throw new Error("the password leaked into the error message");
|
|
202
|
+
}
|
|
203
|
+
status = 407;
|
|
204
|
+
}
|
|
205
|
+
const kind = classifyProxyFailure({ status });
|
|
206
|
+
if (kind !== "proxy-auth") throw new Error(`a wrong password was classified as "${kind}"`);
|
|
207
|
+
return "407 is recognised as a credentials problem";
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await step("the password never appears in output", async () => {
|
|
211
|
+
// The failure with no error message. Checked against the real dial string, which is the
|
|
212
|
+
// one place the secret genuinely lives.
|
|
213
|
+
const dialled = proxyUrl(proxy);
|
|
214
|
+
if (!dialled.includes(PASS)) throw new Error("the self-test is not exercising a password");
|
|
215
|
+
const shown = redactSecrets(`connect failed via ${dialled}`);
|
|
216
|
+
if (shown.includes(PASS)) throw new Error("the password survived redaction");
|
|
217
|
+
if (!shown.includes("***")) throw new Error("redaction removed the password without a trace");
|
|
218
|
+
return "credentials are redacted wherever they are printed";
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
await step("nothing was left running", async () => {
|
|
222
|
+
resetDispatchers();
|
|
223
|
+
return undiciLoaded()
|
|
224
|
+
? "proxy connections closed"
|
|
225
|
+
: "proxy connections closed (undici was never needed)";
|
|
226
|
+
});
|
|
227
|
+
} finally {
|
|
228
|
+
resetDispatchers();
|
|
229
|
+
proxyFx.server.close();
|
|
230
|
+
originFx.server.close();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { ok: checks.every((c) => c.ok), checks };
|
|
234
|
+
}
|
package/proxy-store.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the proxy configuration lives (task #160).
|
|
3
|
+
*
|
|
4
|
+
* A LOCAL FILE, NOT THE SETTINGS ROW, and that is the whole reason this module exists rather
|
|
5
|
+
* than the proxy riding along in Mongo with the model settings. A proxy is a property of the
|
|
6
|
+
* NETWORK a machine is sitting on, not of the user's account: the same account on a laptop at
|
|
7
|
+
* home and a workstation in an office needs the proxy on one and not the other. Storing it
|
|
8
|
+
* per-account would push a corporate proxy onto every machine the moment one of them configured
|
|
9
|
+
* it — and in Client Expert the settings row lives in a database that only that machine can
|
|
10
|
+
* read anyway, so there is nothing to gain.
|
|
11
|
+
*
|
|
12
|
+
* IT ALSO HOLDS A PASSWORD, which decides the file mode. Same treatment as worker.env: 0600,
|
|
13
|
+
* never logged, never echoed. See proxy-config.mjs redactSecrets() for the printing side.
|
|
14
|
+
*/
|
|
15
|
+
import { readFile, writeFile, mkdir, chmod, unlink } from "node:fs/promises";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { normalizeProxy } from "./proxy-config.mjs";
|
|
19
|
+
|
|
20
|
+
const GONEXT_DIR = join(homedir(), ".gonext");
|
|
21
|
+
export const PROXY_FILE = join(GONEXT_DIR, "proxy.json");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The configured proxy, or a disabled empty one.
|
|
25
|
+
*
|
|
26
|
+
* NEVER THROWS. This is read on the startup path of every turn; a corrupt or half-written file
|
|
27
|
+
* must degrade to "no proxy" rather than taking the terminal down with it. The cost of guessing
|
|
28
|
+
* wrong in this direction is a model call that fails with a network error the user can act on —
|
|
29
|
+
* far better than a CLI that will not start.
|
|
30
|
+
*/
|
|
31
|
+
export async function loadProxy() {
|
|
32
|
+
try {
|
|
33
|
+
return normalizeProxy(JSON.parse(await readFile(PROXY_FILE, "utf8")));
|
|
34
|
+
} catch {
|
|
35
|
+
return normalizeProxy({});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Write it back, 0600.
|
|
41
|
+
*
|
|
42
|
+
* chmod AFTER the write, and on every save: the mode of an existing file is not changed by
|
|
43
|
+
* writeFile, so a file created before this rule existed would keep its old permissions forever.
|
|
44
|
+
* Best-effort on platforms where it means nothing (Windows), which is why it is not awaited into
|
|
45
|
+
* a failure.
|
|
46
|
+
*/
|
|
47
|
+
export async function saveProxy(proxy) {
|
|
48
|
+
const p = normalizeProxy(proxy);
|
|
49
|
+
await mkdir(GONEXT_DIR, { recursive: true });
|
|
50
|
+
await writeFile(PROXY_FILE, JSON.stringify(p, null, 2) + "\n", { mode: 0o600 });
|
|
51
|
+
await chmod(PROXY_FILE, 0o600).catch(() => {});
|
|
52
|
+
return p;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Turn it on or off WITHOUT touching the credentials.
|
|
57
|
+
*
|
|
58
|
+
* Deliberately not "disable = delete the file": a proxy that has to be retyped every time it is
|
|
59
|
+
* switched off is one users leave on, which is exactly the state that breaks their machine when
|
|
60
|
+
* they take the laptop home.
|
|
61
|
+
*/
|
|
62
|
+
export async function setProxyEnabled(enabled) {
|
|
63
|
+
return saveProxy({ ...(await loadProxy()), enabled: enabled === true });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Forget it entirely — the escape hatch when the credentials are wrong and confusing things. */
|
|
67
|
+
export async function clearProxy() {
|
|
68
|
+
await unlink(PROXY_FILE).catch(() => {});
|
|
69
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The RAG slice of the agent config the worker hands python (#170).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS ITS OWN FILE. It was eleven lines inside runAgentChatJob, which lives in
|
|
5
|
+
* gu-cli.mjs — a bin script that starts the worker loop on import, so a test cannot reach it
|
|
6
|
+
* without launching a daemon. Untestable in place meant untested, and untested is how the bug
|
|
7
|
+
* below survived: the API had been sending `ragEmbedModelPath` since task #133, the worker never
|
|
8
|
+
* forwarded it, and the branch in gonext_agent_chat.py that reads it had been dead the whole time.
|
|
9
|
+
* Nothing failed. The label just said "from the server URL" for an embedder configured as a
|
|
10
|
+
* folder, and no test could have noticed.
|
|
11
|
+
*
|
|
12
|
+
* THE RULE THIS FILE EXISTS TO ENFORCE: every RAG field the API puts in a job payload reaches
|
|
13
|
+
* python. Dropping one is silent at both ends — the API keeps sending it, python keeps defaulting
|
|
14
|
+
* it — so the two lists have to be pinned against each other rather than kept in step by hand.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Every RAG field the API's job payload carries, with the default to use when it does not.
|
|
19
|
+
*
|
|
20
|
+
* Mirrors the `ragPayload` object in api/src/app.ts (~line 1024). A field added there and not
|
|
21
|
+
* here does not break anything loudly, which is exactly why the test asserts the two agree.
|
|
22
|
+
*
|
|
23
|
+
* The defaults are NOT cosmetic. Empty means "not set", which the agent reports as such — see
|
|
24
|
+
* task #133, where defaulting the embed model to "nomic-embed-text" put a model the user never
|
|
25
|
+
* chose into the logs and the status table, the same lie #170 then had to undo at runtime.
|
|
26
|
+
*/
|
|
27
|
+
export const RAG_JOB_FIELDS = Object.freeze({
|
|
28
|
+
ragEnabled: false,
|
|
29
|
+
// S3 store + the user's own credentials: the worker talks to their bucket directly with these
|
|
30
|
+
// (boto3), NOT via presigned URLs.
|
|
31
|
+
ragS3Location: "",
|
|
32
|
+
ragAwsRegion: "",
|
|
33
|
+
ragAwsAccessKeyId: "",
|
|
34
|
+
ragAwsSecretAccessKey: "",
|
|
35
|
+
// The embedder, in three parts that are not interchangeable: the NAME (only Ollama needs it —
|
|
36
|
+
// it serves many models from one port), the URL, and the model FOLDER a single-model server is
|
|
37
|
+
// started from. The API derives the URL from the folder when no explicit URL is set, so a
|
|
38
|
+
// machine can legitimately have a folder and a derived URL and no name at all.
|
|
39
|
+
ragEmbedModel: "",
|
|
40
|
+
ragEmbedUrl: "",
|
|
41
|
+
ragEmbedModelPath: "",
|
|
42
|
+
ragTopK: 6,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The RAG fields to merge into the agent config, from an API job payload.
|
|
47
|
+
*
|
|
48
|
+
* A pass-through with defaults and nothing else: no validation, no derivation. python re-checks
|
|
49
|
+
* everything it is given (ragMode, ragTopK bounds, whether an embedder is reachable at all),
|
|
50
|
+
* and a second opinion here would only be a second place to be wrong.
|
|
51
|
+
*/
|
|
52
|
+
export function ragJobConfig(payload) {
|
|
53
|
+
const src = payload ?? {};
|
|
54
|
+
const out = {};
|
|
55
|
+
for (const [key, fallback] of Object.entries(RAG_JOB_FIELDS)) {
|
|
56
|
+
out[key] = src[key] ?? fallback;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|