pi-checkup 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +344 -2
- package/bin/checkup.js +233 -0
- package/package.json +47 -4
- package/scripts/lint.js +43 -0
- package/src/checks/binaries.js +371 -0
- package/src/checks/config.js +315 -0
- package/src/checks/index.js +165 -0
- package/src/checks/network.js +543 -0
- package/src/checks/packages.js +367 -0
- package/src/checks/registry.js +338 -0
- package/src/checks/runtime.js +360 -0
- package/src/context.js +79 -0
- package/src/index.js +109 -0
- package/src/render.js +159 -0
- package/src/repair.js +367 -0
- package/src/util.js +839 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
// Network checks — are the endpoints your config points at actually alive?
|
|
2
|
+
//
|
|
3
|
+
// Deliberately provider-agnostic: every OpenAI-compatible provider exposes
|
|
4
|
+
// /models, and a 401/403 still proves the host answered. No vendor SDK, no
|
|
5
|
+
// API key validation (this package never spends your tokens).
|
|
6
|
+
import {
|
|
7
|
+
bareHost,
|
|
8
|
+
curlProbe,
|
|
9
|
+
isLoopbackHost,
|
|
10
|
+
proxyFromEnv,
|
|
11
|
+
readJson,
|
|
12
|
+
redactProxy,
|
|
13
|
+
redactUrl,
|
|
14
|
+
resolveHost,
|
|
15
|
+
scrubCredentials,
|
|
16
|
+
statusIsAlive,
|
|
17
|
+
timedFetch,
|
|
18
|
+
truncateList,
|
|
19
|
+
} from "../util.js";
|
|
20
|
+
|
|
21
|
+
export const group = "Network";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Does this provider declare a header whose value is a credential?
|
|
25
|
+
*
|
|
26
|
+
* Matched by name, never by value: the values are the secrets. Pi interpolates
|
|
27
|
+
* `$NAME` in header values and sends them, so a header carrying a token is the
|
|
28
|
+
* same exposure as an `apiKey` and has to count the same way.
|
|
29
|
+
*/
|
|
30
|
+
function hasCredentialHeader(headers) {
|
|
31
|
+
if (!headers || typeof headers !== "object" || Array.isArray(headers)) return false;
|
|
32
|
+
return Object.keys(headers).some((name) =>
|
|
33
|
+
/(^|[-_])(authorization|auth|api[-_]?key|access[-_]?key|token|secret|password|bearer)([-_]|$)/i.test(name),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function run({ offline = false, agentDir: root, modelsFile, env = process.env, hasRuntime = false }) {
|
|
38
|
+
const results = [];
|
|
39
|
+
const models = readJson(modelsFile);
|
|
40
|
+
if (!models.ok) {
|
|
41
|
+
// Same distinction as Config: a parse failure is corruption, an oversized or
|
|
42
|
+
// unreadable file is not, and saying "not valid JSON" about a valid file is
|
|
43
|
+
// a claim this check never established.
|
|
44
|
+
let reason = "could not be inspected";
|
|
45
|
+
if (models.missing) reason = "is missing";
|
|
46
|
+
else if (models.invalid) reason = "is not valid JSON";
|
|
47
|
+
// "cannot read" covered a missing file and a corrupt one with the same
|
|
48
|
+
// jargon, and those are two different problems with two different fixes.
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
group,
|
|
52
|
+
id: "providers",
|
|
53
|
+
title: "providers",
|
|
54
|
+
status: "skip",
|
|
55
|
+
detail: `models.json ${reason} — no endpoint to probe`,
|
|
56
|
+
hint: models.missing
|
|
57
|
+
? "Add providers to models.json, or configure them through Pi."
|
|
58
|
+
: models.invalid
|
|
59
|
+
? "The Config group reports the parse error."
|
|
60
|
+
: undefined,
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// `providers` must be a non-null object. The `??` fallthrough to the whole
|
|
66
|
+
// file made two wrong shapes read as content: `"providers": null` iterated the
|
|
67
|
+
// top-level keys as if they were providers, and `"providers": 5` silently
|
|
68
|
+
// became "no provider with a baseUrl" — a claim about a config whose
|
|
69
|
+
// providers key is a number. Feeding the bad value back through the normal
|
|
70
|
+
// entry loop makes it land in the named shape row instead.
|
|
71
|
+
const fileData = models.data && typeof models.data === "object" && !Array.isArray(models.data) ? models.data : {};
|
|
72
|
+
const rawProviders = fileData.providers;
|
|
73
|
+
let providers;
|
|
74
|
+
if (rawProviders === undefined) {
|
|
75
|
+
providers = fileData;
|
|
76
|
+
} else if (rawProviders && typeof rawProviders === "object" && !Array.isArray(rawProviders)) {
|
|
77
|
+
providers = rawProviders;
|
|
78
|
+
} else {
|
|
79
|
+
providers = { providers: rawProviders };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Worked out before the offline branch: whether a key would cross the network
|
|
83
|
+
// in the clear is a property of the config, not of the probe. Skipping it in
|
|
84
|
+
// offline mode hid a real misconfiguration from the one mode a locked-down
|
|
85
|
+
// user is most likely to run.
|
|
86
|
+
const cleartext = [];
|
|
87
|
+
const planned = [];
|
|
88
|
+
// Anything dropped for its shape is named here. Dropping silently made the
|
|
89
|
+
// report claim "no provider with a baseUrl" about a config it had just
|
|
90
|
+
// disproved — and a provider whose value is not even an object was the same
|
|
91
|
+
// hole one level up.
|
|
92
|
+
const shape = [];
|
|
93
|
+
const queryWithheld = [];
|
|
94
|
+
const proxy = proxyFromEnv(env);
|
|
95
|
+
for (const [name, provider] of Object.entries(providers)) {
|
|
96
|
+
if (!provider || typeof provider !== "object" || Array.isArray(provider)) {
|
|
97
|
+
shape.push(`${name} (${provider === null ? "null" : Array.isArray(provider) ? "array" : typeof provider}) is not a provider object`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const baseUrl = provider.baseUrl ?? provider.base_url;
|
|
101
|
+
if (baseUrl === undefined || baseUrl === null || baseUrl === "") continue;
|
|
102
|
+
if (typeof baseUrl !== "string") {
|
|
103
|
+
shape.push(`${name} (${Array.isArray(baseUrl) ? "array" : typeof baseUrl}) — baseUrl is not a string`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
let url;
|
|
107
|
+
try {
|
|
108
|
+
url = new URL(baseUrl);
|
|
109
|
+
} catch {
|
|
110
|
+
// Redacted: the usual cause is a hand-pasted baseUrl, and a pasted URL
|
|
111
|
+
// is the one that still has `?key=…` on it.
|
|
112
|
+
planned.push({ name, invalid: redactUrl(baseUrl) });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
// `localhost:11434/v1` — the routine hand-paste with no scheme — parses as
|
|
116
|
+
// scheme `localhost:` with an opaque path and an empty hostname. The probe
|
|
117
|
+
// then printed "null unreachable" and the DNS check invented a failure for
|
|
118
|
+
// the empty string, which is a finding the check never established.
|
|
119
|
+
if (!url.hostname || !/^https?:$/.test(url.protocol)) {
|
|
120
|
+
planned.push({ name, invalid: redactUrl(baseUrl) });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// Appended to the pathname, not to the raw string: `…/v1?api-version=2024`
|
|
124
|
+
// used to become `…/v1?api-version=2024/models`, so the probe asked a
|
|
125
|
+
// different question than the config posed and a 404 was reported as a
|
|
126
|
+
// broken provider.
|
|
127
|
+
url.pathname = url.pathname.replace(/\/+$/, "") + "/models";
|
|
128
|
+
// A gateway configured as https://user:pass@host/v1 cannot be fetched at
|
|
129
|
+
// all: fetch() refuses a URL that carries credentials, so the probe failed
|
|
130
|
+
// with an error that quoted the password back. Moving them into a header
|
|
131
|
+
// makes the probe work and keeps them out of the URL and the message.
|
|
132
|
+
const key = provider.apiKey ?? provider.api_key;
|
|
133
|
+
const hasKey = typeof key === "string" && key.length > 0;
|
|
134
|
+
let userinfo = null;
|
|
135
|
+
let userinfoError = null;
|
|
136
|
+
if (url.username || url.password) {
|
|
137
|
+
// A literal `%` survives the URL parser but throws in decodeURIComponent.
|
|
138
|
+
// The throw used to escape run(), replacing the whole Network group with
|
|
139
|
+
// a crash row that blamed pi-checkup for a typo in the user's config.
|
|
140
|
+
try {
|
|
141
|
+
const pair = `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`;
|
|
142
|
+
userinfo = `Basic ${Buffer.from(pair).toString("base64")}`;
|
|
143
|
+
} catch {
|
|
144
|
+
// Only a blocker when the userinfo was the credential that would have
|
|
145
|
+
// been sent: with an apiKey configured, refusing to probe reported a
|
|
146
|
+
// credentials typo as an unreachable provider.
|
|
147
|
+
if (!hasKey) userinfoError = `${name}: credentials in baseUrl are not valid percent-encoding, so they were not sent`;
|
|
148
|
+
}
|
|
149
|
+
url.username = "";
|
|
150
|
+
url.password = "";
|
|
151
|
+
}
|
|
152
|
+
const loopback = isLoopbackHost(url.hostname);
|
|
153
|
+
// One rule for every credential shape: over plain http to a host that is
|
|
154
|
+
// not this machine, nothing is sent. Testing reachability is not worth
|
|
155
|
+
// putting a live secret on the wire in the clear. The apiKey branch had
|
|
156
|
+
// this guard from the start; userinfo went out as a Basic header, and
|
|
157
|
+
// `?key=…` gateways went out as part of the URL.
|
|
158
|
+
const hasQuery = url.search.length > 1;
|
|
159
|
+
// A custom header is a credential Pi interpolates and Pi sends, so a remote
|
|
160
|
+
// http:// endpoint whose only secret lives in `headers.Authorization` was
|
|
161
|
+
// reported clean by the one row that exists to catch exactly that. Names
|
|
162
|
+
// only — the values are never read here, let alone printed.
|
|
163
|
+
const headerAuth = hasCredentialHeader(provider.headers);
|
|
164
|
+
const cleartextAuth = (hasKey || userinfo || hasQuery || headerAuth) && url.protocol === "http:" && !loopback;
|
|
165
|
+
if (cleartextAuth) cleartext.push(name);
|
|
166
|
+
// A query string cannot be inspected for secrets, so it is not sent at all
|
|
167
|
+
// on a cleartext endpoint: dropping only the parts that look like keys
|
|
168
|
+
// would be guessing. The endpoint is left unprobed rather than probed with
|
|
169
|
+
// a different URL, because a 404 for the stripped request is not a fact
|
|
170
|
+
// about the configured endpoint.
|
|
171
|
+
// Not probed at all (see the probe loop): dropping the query string and
|
|
172
|
+
// sending the rest would answer a question the config did not ask.
|
|
173
|
+
const withheld = cleartextAuth && hasQuery;
|
|
174
|
+
if (withheld) queryWithheld.push(name);
|
|
175
|
+
planned.push({
|
|
176
|
+
name,
|
|
177
|
+
url,
|
|
178
|
+
withheld,
|
|
179
|
+
key: hasKey && !cleartextAuth ? key : null,
|
|
180
|
+
// An explicit apiKey wins over userinfo; a URL carrying both is a user
|
|
181
|
+
// mid-migration, not a configuration to guess about.
|
|
182
|
+
userinfo: hasKey || cleartextAuth ? null : userinfo,
|
|
183
|
+
userinfoError,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const cleartextRow = cleartext.length
|
|
188
|
+
? {
|
|
189
|
+
group,
|
|
190
|
+
id: "cleartext",
|
|
191
|
+
title: "cleartext auth",
|
|
192
|
+
status: "warn",
|
|
193
|
+
detail: `${cleartext.join(", ")} use http:// with credentials configured${queryWithheld.length ? ` (query string on ${queryWithheld.join(", ")} withheld)` : ""}`,
|
|
194
|
+
hint: `Switch the baseUrl to https://, or use a host on localhost. ${
|
|
195
|
+
queryWithheld.length
|
|
196
|
+
? "Endpoints carrying a query string were left unprobed; the rest were probed without the credential, so a 401 there is not a broken endpoint."
|
|
197
|
+
: "The probe was sent without the credential, which is also why a 401 here is not a broken endpoint."
|
|
198
|
+
}`,
|
|
199
|
+
}
|
|
200
|
+
: null;
|
|
201
|
+
|
|
202
|
+
// A baseUrl that cannot be parsed is a config fault, not a network one, so it
|
|
203
|
+
// is named here as well. Offline mode used to report only the endpoints it did
|
|
204
|
+
// not probe, which left `localhost:11434/v1` — the routine hand-paste — with
|
|
205
|
+
// no finding at all on a machine that runs with --offline.
|
|
206
|
+
const notUrl = planned.filter((item) => item.invalid !== undefined).map((item) => `${item.name}: baseUrl is not a URL`);
|
|
207
|
+
const shapeNotes = [...shape, ...notUrl];
|
|
208
|
+
|
|
209
|
+
// Built once so offline mode can emit it too: whether a baseUrl is even a
|
|
210
|
+
// string is a property of the config, not of the probe.
|
|
211
|
+
const shapeRow = shapeNotes.length
|
|
212
|
+
? {
|
|
213
|
+
group,
|
|
214
|
+
id: "baseUrl-shape",
|
|
215
|
+
title: "baseUrl shape",
|
|
216
|
+
status: "warn",
|
|
217
|
+
detail: `${shapeNotes.join("; ")} — not probed`,
|
|
218
|
+
hint: "baseUrl has to be an absolute http(s) URL as a quoted string, like \"https://api.example.com/v1\".",
|
|
219
|
+
}
|
|
220
|
+
: null;
|
|
221
|
+
|
|
222
|
+
if (offline) {
|
|
223
|
+
const rows = [
|
|
224
|
+
{
|
|
225
|
+
group,
|
|
226
|
+
id: "providers",
|
|
227
|
+
title: "providers",
|
|
228
|
+
status: "skip",
|
|
229
|
+
detail: shapeNotes.length
|
|
230
|
+
? `offline mode — ${planned.length} endpoint(s) not probed; ${shapeNotes.join("; ")}`
|
|
231
|
+
: `offline mode — ${planned.length} configured endpoint(s) not probed`,
|
|
232
|
+
hint: "Re-run without --offline to test reachability.",
|
|
233
|
+
},
|
|
234
|
+
// Emitted as a skip rather than omitted: a row that vanishes in offline
|
|
235
|
+
// mode makes the check count and the summary change between two runs of
|
|
236
|
+
// the same tool, which is exactly the kind of drift a health report
|
|
237
|
+
// should not have.
|
|
238
|
+
{ group, id: "dns", title: "dns", status: "skip", detail: "offline mode — no host resolved" },
|
|
239
|
+
];
|
|
240
|
+
if (cleartextRow) rows.push(cleartextRow);
|
|
241
|
+
if (shapeRow) rows.push(shapeRow);
|
|
242
|
+
return rows;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const hosts = new Map();
|
|
246
|
+
const probes = [];
|
|
247
|
+
for (const item of planned) {
|
|
248
|
+
// Drop the name: the hint already prefixes every sentence with the provider,
|
|
249
|
+
// so re-echoing the detail's own prefix printed "openai: openai: invalid…".
|
|
250
|
+
const outcome = (status, detail) => ({ name: item.name, status, detail: detail.replace(`${item.name}: `, "") });
|
|
251
|
+
if (item.invalid !== undefined) {
|
|
252
|
+
probes.push(Promise.resolve(outcome("fail", `${item.name}: invalid baseUrl ${item.invalid}`)));
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const { name, url, key, userinfo, userinfoError, withheld } = item;
|
|
256
|
+
const host = bareHost(url.hostname);
|
|
257
|
+
hosts.set(host, name);
|
|
258
|
+
if (withheld) {
|
|
259
|
+
probes.push(
|
|
260
|
+
Promise.resolve({
|
|
261
|
+
name,
|
|
262
|
+
status: "skip",
|
|
263
|
+
detail: `${name}: not probed — a query string would have crossed the network in the clear`,
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (userinfoError) {
|
|
269
|
+
probes.push(Promise.resolve({ name, status: "warn", detail: userinfoError }));
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const auth = key ? `Bearer ${key}` : userinfo;
|
|
273
|
+
const headers = auth ? { Authorization: auth } : {};
|
|
274
|
+
probes.push(probeEndpoint(url, { headers, proxy }).then((outcome) => ({ ...outcome, name })));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// One line regardless of how many providers are configured. A user with ten
|
|
278
|
+
// endpoints should not get ten lines of green ticks; the detail is still
|
|
279
|
+
// there under `verbose`.
|
|
280
|
+
const outcomes = await Promise.all(probes);
|
|
281
|
+
const unhealthy = outcomes.filter((o) => o.status === "warn" || o.status === "fail");
|
|
282
|
+
const unprobed = outcomes.filter((o) => o.status === "skip");
|
|
283
|
+
// The denominator is the endpoints the probe actually judged. Probes that were
|
|
284
|
+
// withheld (a query string that would cross the network in the clear, a missing
|
|
285
|
+
// curl, a request that could not be built) appear in the detail as unprobed —
|
|
286
|
+
// counting them as judged said "1 of 3 unhealthy" where only two were asked.
|
|
287
|
+
const probed = outcomes.length - unprobed.length;
|
|
288
|
+
if (!outcomes.length) {
|
|
289
|
+
results.push({
|
|
290
|
+
group,
|
|
291
|
+
id: "providers",
|
|
292
|
+
title: "providers",
|
|
293
|
+
status: "skip",
|
|
294
|
+
detail: shapeNotes.length
|
|
295
|
+
? `no provider with a usable baseUrl — ${shapeNotes.join("; ")}`
|
|
296
|
+
: "no provider with a baseUrl in models.json",
|
|
297
|
+
});
|
|
298
|
+
} else if (!unhealthy.length && unprobed.length) {
|
|
299
|
+
// Nothing was sent, so there is nothing to call healthy — but when some
|
|
300
|
+
// endpoints did answer, saying only "skipped" hid the ones that were proven.
|
|
301
|
+
const answered = outcomes.length - unprobed.length;
|
|
302
|
+
results.push({
|
|
303
|
+
group,
|
|
304
|
+
id: "providers",
|
|
305
|
+
title: "providers",
|
|
306
|
+
status: "skip",
|
|
307
|
+
detail: `${answered ? `${answered} endpoint(s) reachable; ` : ""}${unprobed.map((o) => o.detail).join("; ")}`,
|
|
308
|
+
});
|
|
309
|
+
} else if (!unhealthy.length) {
|
|
310
|
+
results.push({
|
|
311
|
+
group,
|
|
312
|
+
id: "providers",
|
|
313
|
+
title: "providers",
|
|
314
|
+
status: "ok",
|
|
315
|
+
detail: `${probed} endpoint(s) reachable`,
|
|
316
|
+
});
|
|
317
|
+
} else {
|
|
318
|
+
results.push({
|
|
319
|
+
group,
|
|
320
|
+
id: "providers",
|
|
321
|
+
title: "providers",
|
|
322
|
+
status: unhealthy.some((o) => o.status === "fail") ? "fail" : "warn",
|
|
323
|
+
detail: `${unhealthy.length} of ${probed} unhealthy: ${truncateList(unhealthy.map((o) => o.name), 6)}`,
|
|
324
|
+
// An outcome that knows why it failed brings its own advice; the generic
|
|
325
|
+
// sentence is only for the ones that do not (a refused connection, a
|
|
326
|
+
// timeout) and for the ones that sit next to them.
|
|
327
|
+
hint: [
|
|
328
|
+
`${unhealthy.map((o) => `${o.name}: ${o.detail}`).join("; ")}.`,
|
|
329
|
+
// Each hint is attributed. Unattributed, two endpoints failing for
|
|
330
|
+
// different reasons produced one paragraph whose sentences contradicted
|
|
331
|
+
// each other — "the request was cut off locally" followed by "the
|
|
332
|
+
// hostname does not resolve" — with nothing saying which was which.
|
|
333
|
+
...new Set(unhealthy.filter((o) => o.hint).map((o) => `${o.name}: ${o.hint}`)),
|
|
334
|
+
unhealthy.some((o) => !o.hint) ? "Check the baseUrl, whether the server is running, and whether a proxy is required." : "",
|
|
335
|
+
]
|
|
336
|
+
.filter(Boolean)
|
|
337
|
+
.join(" "),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (cleartextRow) results.push(cleartextRow);
|
|
342
|
+
if (shapeRow) results.push(shapeRow);
|
|
343
|
+
|
|
344
|
+
// --- DNS ---------------------------------------------------------------
|
|
345
|
+
// A host that resolves to loopback while the baseUrl is remote, or does not
|
|
346
|
+
// resolve at all, explains a whole class of "it worked yesterday" failures.
|
|
347
|
+
// Loopback hosts are excluded from the count as well as from the lookup: a
|
|
348
|
+
// host this check deliberately skips is not a host that "resolved normally".
|
|
349
|
+
const dnsTargets = [...hosts].filter(([host]) => !isLoopbackHost(host));
|
|
350
|
+
const dnsChecks = await Promise.all(
|
|
351
|
+
dnsTargets.map(async ([host, provider]) => {
|
|
352
|
+
const res = await resolveHost(host);
|
|
353
|
+
if (!res.ok) {
|
|
354
|
+
// A deadline is this process giving up, not the resolver answering.
|
|
355
|
+
// Reporting it as "does not resolve" failed a host that resolves fine
|
|
356
|
+
// on a slow corporate resolver.
|
|
357
|
+
return {
|
|
358
|
+
host,
|
|
359
|
+
provider,
|
|
360
|
+
status: res.timedOut ? "warn" : "fail",
|
|
361
|
+
detail: res.timedOut
|
|
362
|
+
? `${host} resolution timed out (${res.error}) — not proven broken`
|
|
363
|
+
: `${host} does not resolve — ${res.error}`,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
const loopback = res.addresses.every((a) => a === "::1" || a.startsWith("127."));
|
|
367
|
+
if (loopback) {
|
|
368
|
+
return {
|
|
369
|
+
host,
|
|
370
|
+
provider,
|
|
371
|
+
status: "warn",
|
|
372
|
+
detail: `${host} resolves only to loopback (${res.addresses.join(", ")}) for provider "${provider}"`,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
return null;
|
|
376
|
+
}),
|
|
377
|
+
);
|
|
378
|
+
const dnsProblems = dnsChecks.filter(Boolean);
|
|
379
|
+
const anyUnresolved = dnsProblems.some((p) => p.status === "fail");
|
|
380
|
+
if (dnsProblems.length) {
|
|
381
|
+
results.push({
|
|
382
|
+
group,
|
|
383
|
+
id: "dns",
|
|
384
|
+
title: "dns",
|
|
385
|
+
status: anyUnresolved ? "fail" : "warn",
|
|
386
|
+
detail: dnsProblems.map((p) => p.detail).join("; "),
|
|
387
|
+
// The in-session command has no --offline flag — naming a flag the reader
|
|
388
|
+
// cannot pass made the hint a dead end exactly when Pi would not boot.
|
|
389
|
+
hint: hasRuntime
|
|
390
|
+
? "Fix DNS or use a proxy. To skip every network check, run `npx pi-checkup --offline` outside the session. A hosts-file override is usually the fastest repair."
|
|
391
|
+
: "Fix DNS or use a proxy, or pass --offline to skip every network check. A hosts-file override is usually the fastest repair.",
|
|
392
|
+
});
|
|
393
|
+
} else if (!dnsTargets.length) {
|
|
394
|
+
results.push({
|
|
395
|
+
group,
|
|
396
|
+
id: "dns",
|
|
397
|
+
title: "dns",
|
|
398
|
+
status: "skip",
|
|
399
|
+
detail: hosts.size ? "every provider host is loopback" : "no provider host to resolve",
|
|
400
|
+
});
|
|
401
|
+
} else {
|
|
402
|
+
results.push({
|
|
403
|
+
group,
|
|
404
|
+
id: "dns",
|
|
405
|
+
title: "dns",
|
|
406
|
+
status: "ok",
|
|
407
|
+
detail: `${dnsTargets.length} provider host(s) resolve normally`,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return results;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** One-line verdict for a provider probe, kept out of the ternary chain. */
|
|
415
|
+
/**
|
|
416
|
+
* curl's own stderr, with the proxy it failed on replaced by its redacted form.
|
|
417
|
+
*
|
|
418
|
+
* curl quotes the configured proxy verbatim in `Could not resolve proxy: …`, so
|
|
419
|
+
* a proxy value that is nothing but a token reached the report through this
|
|
420
|
+
* path even though the row itself printed `(configured)`.
|
|
421
|
+
*/
|
|
422
|
+
function scrubCurlError(text, proxy) {
|
|
423
|
+
const scrubbed = scrubCredentials(String(text ?? ""));
|
|
424
|
+
if (!proxy) return scrubbed;
|
|
425
|
+
return scrubbed.split(String(proxy)).join(redactProxy(proxy));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Probe one endpoint, through the proxy when one is configured.
|
|
430
|
+
*
|
|
431
|
+
* Node's global fetch ignores `https_proxy` and friends, so a user behind a
|
|
432
|
+
* corporate proxy saw "unreachable" for endpoints that work in their browser —
|
|
433
|
+
* a false finding in a tool whose only job is to avoid them. curl honours the
|
|
434
|
+
* proxy natively; it is already a dependency of the opt-in forwarding test.
|
|
435
|
+
*/
|
|
436
|
+
async function probeEndpoint(url, { headers, proxy }) {
|
|
437
|
+
// A header value with a newline or a character outside Latin-1 is not an HTTP
|
|
438
|
+
// header value. fetch refuses to build the request; curl would silently send a
|
|
439
|
+
// different key (line breaks stripped) and a 401 from that request says nothing
|
|
440
|
+
// about the configured one. Neither path is allowed to make the attempt, and
|
|
441
|
+
// neither message may quote the value.
|
|
442
|
+
const badHeader = Object.values(headers ?? {}).some(
|
|
443
|
+
(value) => value !== undefined && value !== null && /[\r\n]|[^\u0000-\u00ff]/.test(String(value)),
|
|
444
|
+
);
|
|
445
|
+
if (badHeader) {
|
|
446
|
+
// A warning, not a skip: this is a fault in the config, not a probe that
|
|
447
|
+
// could not run. Nothing was sent, so the endpoint is still unjudged — the
|
|
448
|
+
// row must say that in the same breath, or it reads as "the server is down".
|
|
449
|
+
return {
|
|
450
|
+
status: "warn",
|
|
451
|
+
detail: "not probed: a configured header value cannot be sent as an HTTP header (a newline or a non-Latin-1 character)",
|
|
452
|
+
hint: "Fix the apiKey or headers for this provider in models.json — the value is rejected before the request leaves the machine.",
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
if (!proxy) {
|
|
456
|
+
const res = await timedFetch(url.toString(), { headers });
|
|
457
|
+
// A request Node refuses to construct never opens a socket. `describeNetError`
|
|
458
|
+
// already classifies that as `kind: "request"` without quoting the value —
|
|
459
|
+
// undici's own message embeds it, and the value here is the apiKey.
|
|
460
|
+
if (res.kind === "request") {
|
|
461
|
+
return { status: "skip", detail: "not probed: the request could not be built — a header value or baseUrl the fetch API rejects" };
|
|
462
|
+
}
|
|
463
|
+
// 401/403 means the endpoint is up and merely disagrees about auth, which
|
|
464
|
+
// is a configuration problem, not a reachability problem.
|
|
465
|
+
const alive = res.ok && (res.status === 200 || res.status === 401 || res.status === 403);
|
|
466
|
+
// A refused connection, a rejected certificate, an HTTP 5xx: the endpoint was
|
|
467
|
+
// asked and answered with something other than life. That is the finding this
|
|
468
|
+
// check exists to report, and reporting it as a warning exited 0 — CI and
|
|
469
|
+
// `npx pi-checkup && start-pi` sailed past the one row that said unhealthy.
|
|
470
|
+
// Only a local timeout stays a warning: the deadline is this process's, and
|
|
471
|
+
// the detail already refuses to call the endpoint unreachable. A response
|
|
472
|
+
// status — 503 and 404 included — is a definitive answer from the endpoint,
|
|
473
|
+
// so the comment above and the status have to agree.
|
|
474
|
+
const failed = !alive && !res.aborted;
|
|
475
|
+
return { status: alive ? "ok" : failed ? "fail" : "warn", detail: describeProbe(url, res, alive), hint: probeHint(res) };
|
|
476
|
+
}
|
|
477
|
+
const res = await curlProbe(url.toString(), { proxy, headers });
|
|
478
|
+
if (res.setupFailed) {
|
|
479
|
+
// The probe never left the machine; blaming the endpoint would be a finding
|
|
480
|
+
// this check did not establish.
|
|
481
|
+
return { status: "skip", detail: `not probed: ${scrubCurlError(res.error, proxy)}` };
|
|
482
|
+
}
|
|
483
|
+
if (res.missing) {
|
|
484
|
+
return { status: "skip", detail: "not probed: a proxy is configured but curl is not installed" };
|
|
485
|
+
}
|
|
486
|
+
const code = Number(res.code);
|
|
487
|
+
const alive = statusIsAlive(code);
|
|
488
|
+
if (alive) {
|
|
489
|
+
return { status: "ok", detail: `${redactUrl(url.toString())} answered ${code} through ${redactProxy(proxy)}` };
|
|
490
|
+
}
|
|
491
|
+
// The proxy path failed. Node's fetch ignores `https_proxy`, so Pi's own
|
|
492
|
+
// requests take the direct path — and a run inside Pi would then call the same
|
|
493
|
+
// endpoint reachable while the CLI called it dead. Ask the other path before
|
|
494
|
+
// blaming the endpoint.
|
|
495
|
+
const direct = await timedFetch(url.toString(), { headers });
|
|
496
|
+
const directAlive = direct.ok && statusIsAlive(direct.status);
|
|
497
|
+
const through = `through ${redactProxy(proxy)}`;
|
|
498
|
+
if (directAlive) {
|
|
499
|
+
return {
|
|
500
|
+
// A config problem either way (the proxy path is the one Pi's own requests
|
|
501
|
+
// do not take), and the detail says so — not a confirmed-dead endpoint.
|
|
502
|
+
status: "warn",
|
|
503
|
+
detail: `${redactUrl(url.toString())} answered HTTP ${direct.status} directly but not ${through} (${scrubCurlError(res.error, proxy) || `HTTP ${code}`}) — Pi's own requests ignore https_proxy`,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
// Both paths failed. curl got a definitive answer (a refused connection, a
|
|
507
|
+
// rejected certificate, an HTTP error status) — the endpoint did not answer,
|
|
508
|
+
// which is the finding this check exists to report.
|
|
509
|
+
const failed = Boolean(res.code) || !/no response/.test(String(res.error ?? ""));
|
|
510
|
+
return {
|
|
511
|
+
status: failed ? "fail" : "warn",
|
|
512
|
+
detail: `${redactUrl(url.toString())} did not answer ${through}${code ? ` (HTTP ${code})` : ""}; ${scrubCurlError(res.error, proxy) || "no response"}${direct.status ? ` — and HTTP ${direct.status} directly` : ""}`,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function describeProbe(url, res, alive) {
|
|
517
|
+
// `url.origin` never carries userinfo, and `res.error` is prose from
|
|
518
|
+
// `describeNetError`, so neither can quote a credential or an errno.
|
|
519
|
+
if (alive) return `${url.origin} reachable (HTTP ${res.status})`;
|
|
520
|
+
if (res.status) return `${url.origin} answered HTTP ${res.status}`;
|
|
521
|
+
// A local timeout is this process giving up. Saying "unreachable" asserted a
|
|
522
|
+
// fact about the endpoint that an 8s deadline did not establish, and the same
|
|
523
|
+
// file already refuses to make that claim on the DNS path.
|
|
524
|
+
if (res.aborted) {
|
|
525
|
+
// The deadline is already named by `error`; repeating it in parentheses made
|
|
526
|
+
// the line read "did not answer in time (no answer within 8s)".
|
|
527
|
+
return `${url.origin} did not answer within ${Math.round((res.timeoutMs ?? 8000) / 1000)}s — not proven unreachable`;
|
|
528
|
+
}
|
|
529
|
+
return `${url.origin} unreachable (${res.error})`;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Advice that matches the failure, rather than one sentence for all of them. */
|
|
533
|
+
function probeHint(res) {
|
|
534
|
+
if (res.aborted) return "The request was cut off locally, which says nothing about the endpoint. Re-run, or raise the timeout on a slow link.";
|
|
535
|
+
if (res.kind === "ENOTFOUND" || res.kind === "EAI_AGAIN" || res.kind === "EAI_NODATA") {
|
|
536
|
+
return "The hostname does not resolve. Check the spelling of baseUrl, or the proxy or DNS settings of this machine.";
|
|
537
|
+
}
|
|
538
|
+
if (res.kind === "ECONNREFUSED") return "Nothing is listening on that host and port. Check that the server is running, and whether baseUrl has the right port.";
|
|
539
|
+
if (/^(CERT_|DEPTH_ZERO|SELF_SIGNED|UNABLE_TO_VERIFY|ERR_TLS|ERR_SSL)/.test(String(res.kind))) {
|
|
540
|
+
return "The TLS certificate was rejected. A corporate proxy that terminates TLS needs its CA added to Node's trust store.";
|
|
541
|
+
}
|
|
542
|
+
return "Check the baseUrl, whether the server is running, and whether a proxy is required.";
|
|
543
|
+
}
|