acuvo-code 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
package/lib/doctor.mjs
ADDED
|
@@ -0,0 +1,2139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ THE DOCTOR — ONE COMMAND THAT SAYS WHAT IS ACTUALLY WORKING ─────────
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ THIS FILE IS PAID FOR BY A MEASURED HOUR. Four media tools were silently
|
|
5
|
+
* dark because ONE undocumented environment variable (`MODAL_VIDEO_SECRET`) was
|
|
6
|
+
* missing. The error the agent saw was *"the speech service returned no audio"*
|
|
7
|
+
* — which reads TRANSIENT, so it retried four times and burned six rounds on a
|
|
8
|
+
* call that could never have succeeded. An hour went into diagnosing a
|
|
9
|
+
* thirty-second question.
|
|
10
|
+
*
|
|
11
|
+
* ── ⚠️⚠️ THE ONE RULE THIS FILE EXISTS TO ENFORCE: A 200 IS NOT HEALTH ──────
|
|
12
|
+
*
|
|
13
|
+
* MEASURED LIVE, 2026-08-11, against all four Modal endpoints:
|
|
14
|
+
*
|
|
15
|
+
* POST {} -> 200 {"ok":false,"error":"unauthorised"}
|
|
16
|
+
* POST {secret} -> 200 {"ok":false,"error":"empty text"}
|
|
17
|
+
*
|
|
18
|
+
* Both are HTTP 200. The FIRST is a dead capability; the SECOND is a healthy
|
|
19
|
+
* service complaining about a deliberately-empty payload — which is exactly
|
|
20
|
+
* the proof we want, because a payload complaint can only be reached AFTER the
|
|
21
|
+
* credential was accepted. So the health probe here sends the secret and NO
|
|
22
|
+
* payload: it costs no GPU-seconds, it returns in ~300ms, and it distinguishes
|
|
23
|
+
* "your key is wrong" from "your request was wrong". Believing the 200 is
|
|
24
|
+
* precisely the bug that cost the hour.
|
|
25
|
+
*
|
|
26
|
+
* ── ⭐ THE OUTPUT IS THE PRODUCT ────────────────────────────────────────────
|
|
27
|
+
* Three states, and nothing else:
|
|
28
|
+
*
|
|
29
|
+
* live it is configured AND something proved it works
|
|
30
|
+
* dark it is switched off / not configured — nothing is wrong, but the
|
|
31
|
+
* capability is not there and the model will never be offered it
|
|
32
|
+
* broken it is configured and it DOES NOT WORK
|
|
33
|
+
*
|
|
34
|
+
* ⚠️ AND EVERY dark OR broken LINE NAMES THE EXACT VARIABLE OR ACTION. A
|
|
35
|
+
* diagnostic that says "TTS: unavailable" has done nothing — the reader is
|
|
36
|
+
* exactly where they started. "TTS: dark — MODAL_TTS_URL unset" ends the
|
|
37
|
+
* investigation in one line. The word "unavailable" is banned here and a test
|
|
38
|
+
* pins that.
|
|
39
|
+
*
|
|
40
|
+
* ── ⚠️ THE FOURTH THING THAT IS NOT A STATE: `verified` ─────────────────────
|
|
41
|
+
* A machine with no network is NOT a broken machine, and a doctor that paints
|
|
42
|
+
* an offline laptop red is a check that fails correct work — the failure mode
|
|
43
|
+
* this repo has now been bitten by six times. So when every single probe fails
|
|
44
|
+
* at the TRANSPORT layer (no HTTP status ever arrived), the endpoints keep the
|
|
45
|
+
* state their configuration earns and carry `verified: false` plus the words
|
|
46
|
+
* "could not check". One host down while the others answer is a different
|
|
47
|
+
* story and IS reported broken, because then we have proof.
|
|
48
|
+
*
|
|
49
|
+
* ── ⚠️⚠️ NEVER PRINT A SECRET. NOT A PREFIX, NOT A SUFFIX, NOT A LENGTH ─────
|
|
50
|
+
* The temptation is real and there is a specific trap: OpenRouter's own
|
|
51
|
+
* `GET /api/v1/key` answers with `{"data":{"label":"sk-or-v1-abc...xyz"}}` —
|
|
52
|
+
* a prefix AND a suffix of a live credential, handed to us by the service we
|
|
53
|
+
* are checking. Reading that field would leak a key into terminal scrollback,
|
|
54
|
+
* CI logs and every pasted bug report. So: this file reports "present" or
|
|
55
|
+
* "absent", it reads `label` from nothing, and `scrubSecrets` runs over the
|
|
56
|
+
* WHOLE report on the way out as a second line of defence.
|
|
57
|
+
*
|
|
58
|
+
* ── ⚠️ IT MUST NEVER HANG ───────────────────────────────────────────────────
|
|
59
|
+
* Every probe is bounded twice — an `AbortSignal.timeout` for the real network
|
|
60
|
+
* and a `withTimeout` race for a `fetchImpl` that ignores signals — and they
|
|
61
|
+
* all run concurrently. A diagnostic you have to wait thirty seconds for is one
|
|
62
|
+
* people stop running.
|
|
63
|
+
*
|
|
64
|
+
* ── PURITY ─────────────────────────────────────────────────────────────────
|
|
65
|
+
* Everything that decides anything is a pure function of data: `assessProbe`,
|
|
66
|
+
* `isOffline`, `checkNodeVersion`, `gitignoreCoversAcuvo`, `scrubSecrets`,
|
|
67
|
+
* `summarise`, `formatDoctor`. `runDoctor` is the only edge, and it takes its
|
|
68
|
+
* clock, its fetch, its git and its filesystem as arguments.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
|
|
72
|
+
import { join, dirname, resolve } from 'node:path';
|
|
73
|
+
import { fileURLToPath } from 'node:url';
|
|
74
|
+
|
|
75
|
+
import { readMcpConfig, MCP_CONFIG_FILES, MAX_SERVERS } from './mcp.mjs';
|
|
76
|
+
import { CATALOGUE, assessCatalogue, packageOf, STARTER_CONFIG_FILE } from './mcp-defaults.mjs';
|
|
77
|
+
import { readModelConfig, DEFAULT_MODEL } from './model.mjs';
|
|
78
|
+
import { buildChain } from './chain.mjs';
|
|
79
|
+
import { mediaConfig, MEDIA_SECRET_ENV_NAMES } from './media.mjs';
|
|
80
|
+
import { editConfig } from './image-edit.mjs';
|
|
81
|
+
import { imageConfig, engineConfig, generateEndpoint, IMAGE_URL_ENV, IMAGE_TOKEN_ENV, ENGINE_SECRET_ENV } from './imagegen.mjs';
|
|
82
|
+
import { resolveCommandAllowlist, COMMANDS_CONFIG_FILE, ALLOW_COMMANDS_ENV, PRESET_NAMES } from './command.mjs';
|
|
83
|
+
import { TOOL_NAMES, toolNamesForRounds, languagesPresent } from './tools.mjs';
|
|
84
|
+
import { LANGUAGE_SERVERS } from './lsp.mjs';
|
|
85
|
+
import { gitStatus as realGitStatus, ALLOW_PUSH_ENV, PROTECTED_BRANCHES } from './git.mjs';
|
|
86
|
+
|
|
87
|
+
/** ⚠️ THREE. Adding a fourth is how "unknown" becomes a place to hide. */
|
|
88
|
+
export const DOCTOR_STATES = ['live', 'dark', 'broken'];
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The credentials this package reads. Used only to know what to SCRUB — the
|
|
92
|
+
* values are never rendered, compared against the output, or counted.
|
|
93
|
+
*/
|
|
94
|
+
export const SECRET_ENV_VARS = [
|
|
95
|
+
'OPENROUTER_API_KEY',
|
|
96
|
+
'MODAL_VIDEO_SECRET',
|
|
97
|
+
IMAGE_TOKEN_ENV,
|
|
98
|
+
'GITHUB_TOKEN',
|
|
99
|
+
'GH_TOKEN',
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
/** OpenRouter's free, tokenless endpoints. A model call would cost money and prove less. */
|
|
103
|
+
const OR_KEY_URL = 'https://openrouter.ai/api/v1/key';
|
|
104
|
+
const OR_CREDITS_URL = 'https://openrouter.ai/api/v1/credits';
|
|
105
|
+
const OR_MODELS_URL = 'https://openrouter.ai/api/v1/models';
|
|
106
|
+
|
|
107
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 10_000;
|
|
108
|
+
|
|
109
|
+
/** The directory the CLI writes sessions and audit logs into, inside the USER's repo. */
|
|
110
|
+
const ACUVO_DIR = '.acuvo';
|
|
111
|
+
|
|
112
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
113
|
+
// PURE: TIME AND VERSIONS
|
|
114
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Bound a promise without leaking a timer.
|
|
118
|
+
*
|
|
119
|
+
* ⚠️ `clearTimeout` IS THE POINT, not the race. A doctor that finished in 400ms
|
|
120
|
+
* and then held the process open for the remaining ten seconds of every probe
|
|
121
|
+
* timeout would look like a hang to the only observer who matters — the person
|
|
122
|
+
* waiting for their shell prompt back.
|
|
123
|
+
*
|
|
124
|
+
* ⚠️ AND IT IS NEEDED EVEN THOUGH EVERY REQUEST CARRIES AN AbortSignal. A
|
|
125
|
+
* `fetchImpl` handed in by a caller (or a test) is under no obligation to
|
|
126
|
+
* honour the signal, and "we trusted the injected dependency" is not a bound.
|
|
127
|
+
*
|
|
128
|
+
* ⚠️⚠️ THE TIMER IS DELIBERATELY **NOT** unref'd, and the first draft got this
|
|
129
|
+
* backwards. An unref'd alarm does not hold the event loop open, so when the
|
|
130
|
+
* thing being bounded is a promise that never settles — precisely the case this
|
|
131
|
+
* function exists for — the loop drains and the await never returns. Node's own
|
|
132
|
+
* test runner names it exactly: *"Promise resolution is still pending but the
|
|
133
|
+
* event loop has already resolved"*. The leak this was meant to avoid is
|
|
134
|
+
* handled by `clearTimeout` in the `finally`, which runs on every path.
|
|
135
|
+
*/
|
|
136
|
+
export function withTimeout(promise, ms, fallback) {
|
|
137
|
+
let timer = null;
|
|
138
|
+
const alarm = new Promise((resolve) => { timer = setTimeout(() => resolve(fallback), ms); });
|
|
139
|
+
return Promise.race([promise, alarm]).finally(() => { if (timer) clearTimeout(timer); });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The minimum major version an `engines` range asks for.
|
|
144
|
+
*
|
|
145
|
+
* ⚠️ DELIBERATELY NOT A SEMVER IMPLEMENTATION. This package has zero
|
|
146
|
+
* dependencies forever, and the question is small: what is the lowest major
|
|
147
|
+
* that satisfies this string. `>=20`, `>=20.0.0`, `^22.0.0` and `22.x` are the
|
|
148
|
+
* shapes a package.json really carries.
|
|
149
|
+
*
|
|
150
|
+
* ⭐ RETURNS null RATHER THAN GUESSING. `*`, `latest`, an absent field and
|
|
151
|
+
* anything unparseable all mean "we do not know", and a doctor that invents a
|
|
152
|
+
* requirement will fail a machine that is completely fine.
|
|
153
|
+
*/
|
|
154
|
+
export function parseEnginesRange(spec) {
|
|
155
|
+
if (typeof spec !== 'string') return null;
|
|
156
|
+
const m = /(\d+)/.exec(spec);
|
|
157
|
+
if (!m) return null;
|
|
158
|
+
return Number(m[1]);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* ⚠️ AN UNPARSEABLE VERSION IS NOT A FAILING VERSION. Node is obviously running
|
|
163
|
+
* — it is running this code — so the worst honest answer is "we could not
|
|
164
|
+
* compare", never "your runtime is broken".
|
|
165
|
+
*/
|
|
166
|
+
export function checkNodeVersion(version, enginesSpec) {
|
|
167
|
+
const min = parseEnginesRange(enginesSpec);
|
|
168
|
+
const running = /v?(\d+)/.exec(String(version ?? ''));
|
|
169
|
+
const major = running ? Number(running[1]) : null;
|
|
170
|
+
|
|
171
|
+
if (min === null) {
|
|
172
|
+
return {
|
|
173
|
+
id: 'runtime.node',
|
|
174
|
+
label: 'node',
|
|
175
|
+
state: 'live',
|
|
176
|
+
verified: true,
|
|
177
|
+
detail: `${version} — no engines range to compare against`,
|
|
178
|
+
fix: null,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (major === null) {
|
|
182
|
+
return {
|
|
183
|
+
id: 'runtime.node',
|
|
184
|
+
label: 'node',
|
|
185
|
+
state: 'live',
|
|
186
|
+
verified: false,
|
|
187
|
+
detail: `could not read a major version out of "${version}"; engines asks for >=${min}`,
|
|
188
|
+
fix: null,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (major < min) {
|
|
192
|
+
return {
|
|
193
|
+
id: 'runtime.node',
|
|
194
|
+
label: 'node',
|
|
195
|
+
state: 'broken',
|
|
196
|
+
verified: true,
|
|
197
|
+
detail: `${version} is below the engines floor of >=${min}`,
|
|
198
|
+
fix: `upgrade Node to ${min} or newer — https://nodejs.org (this package uses Node ${min}+ built-ins and has no dependencies to fall back on)`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
id: 'runtime.node',
|
|
203
|
+
label: 'node',
|
|
204
|
+
state: 'live',
|
|
205
|
+
verified: true,
|
|
206
|
+
detail: `${version} satisfies engines >=${min}`,
|
|
207
|
+
fix: null,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
212
|
+
// PURE: PROBE VERDICTS
|
|
213
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* True only when EVERY network probe failed before an HTTP status arrived.
|
|
217
|
+
*
|
|
218
|
+
* ⚠️ A REFUSAL OR A 404 PROVES THE NETWORK WORKS, so one of those anywhere in
|
|
219
|
+
* the set means this machine is online and a dead host is genuinely dead. The
|
|
220
|
+
* distinction is the whole reason an offline laptop does not light up red.
|
|
221
|
+
*/
|
|
222
|
+
export function isOffline(probes) {
|
|
223
|
+
const seen = (probes ?? []).filter(Boolean);
|
|
224
|
+
if (seen.length === 0) return false;
|
|
225
|
+
return seen.every((p) => p.kind === 'unreachable');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Turn one probe result into a check verdict.
|
|
230
|
+
*
|
|
231
|
+
* kinds:
|
|
232
|
+
* ok the service answered and the credential was accepted
|
|
233
|
+
* refused the service answered and rejected the credential (often HTTP 200!)
|
|
234
|
+
* http the service answered with a status or body we cannot call healthy
|
|
235
|
+
* unreachable no HTTP status ever arrived
|
|
236
|
+
* unchecked we deliberately did not probe (no fetch, or no health route)
|
|
237
|
+
*/
|
|
238
|
+
export function assessProbe(probe, { envVar, secretVar = 'MODAL_VIDEO_SECRET', offline = false, host = null } = {}) {
|
|
239
|
+
const where = host ? ` (${host})` : '';
|
|
240
|
+
switch (probe?.kind) {
|
|
241
|
+
case 'ok':
|
|
242
|
+
return { state: 'live', verified: true, detail: `configured${where} · reachable and authorised${probe.detail ? ` — ${probe.detail}` : ''}`, fix: null };
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* ⭐⭐ THE LINE THAT PAYS FOR THE FILE. The service said 200 and it said no.
|
|
246
|
+
* The fix is a credential, and NOTHING about it is transient — so the
|
|
247
|
+
* message must not read like something worth retrying.
|
|
248
|
+
*/
|
|
249
|
+
case 'refused':
|
|
250
|
+
return {
|
|
251
|
+
state: 'broken',
|
|
252
|
+
verified: true,
|
|
253
|
+
detail: `configured${where} · the service REFUSED the credential — ${probe.detail || 'unauthorised'} (it answered HTTP 200; a 200 is not proof of health)`,
|
|
254
|
+
fix: `set ${secretVar} to the value this endpoint expects — retrying will not help`,
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
case 'http':
|
|
258
|
+
return {
|
|
259
|
+
state: 'broken',
|
|
260
|
+
verified: true,
|
|
261
|
+
detail: `configured${where} · ${probe.detail || 'the service answered with an error'}`,
|
|
262
|
+
fix: `check ${envVar} points at the right service, and that the service is deployed`,
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
case 'unreachable':
|
|
266
|
+
if (offline) {
|
|
267
|
+
return {
|
|
268
|
+
state: 'live',
|
|
269
|
+
verified: false,
|
|
270
|
+
detail: `configured${where} · could not check — nothing on this machine reached the network`,
|
|
271
|
+
fix: `reconnect and re-run the doctor to verify ${envVar}`,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
state: 'broken',
|
|
276
|
+
verified: true,
|
|
277
|
+
detail: `configured${where} · unreachable while other services answered — ${probe.detail || 'no response'}`,
|
|
278
|
+
fix: `check ${envVar} — the host may be wrong, or the service scaled to zero and failed to start`,
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
case 'unchecked':
|
|
282
|
+
default:
|
|
283
|
+
return {
|
|
284
|
+
state: 'live',
|
|
285
|
+
verified: false,
|
|
286
|
+
detail: `configured${where} · ${probe?.detail || 'not checked'}`,
|
|
287
|
+
fix: null,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Count the states, and count separately how much of the green was never proved. */
|
|
293
|
+
export function summarise(checks) {
|
|
294
|
+
const out = { live: 0, dark: 0, broken: 0, unverified: 0 };
|
|
295
|
+
for (const c of checks ?? []) {
|
|
296
|
+
if (out[c.state] !== undefined) out[c.state] += 1;
|
|
297
|
+
if (c.verified === false) out.unverified += 1;
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
303
|
+
// PURE: THE WORKSPACE
|
|
304
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Does this .gitignore body keep `.acuvo/` out of the user's repository?
|
|
308
|
+
*
|
|
309
|
+
* ⚠️ THIS MATTERS BECAUSE WE WRITE INTO SOMEBODY ELSE'S REPO. Sessions, audit
|
|
310
|
+
* logs and generated screenshots land in `.acuvo/`, and an un-ignored `.acuvo/`
|
|
311
|
+
* turns every `git status` into noise and every `git add -A` into a commit of
|
|
312
|
+
* our transcripts — which can contain the user's own prompts.
|
|
313
|
+
*
|
|
314
|
+
* ⚠️ A LATER `!.acuvo/` WINS, exactly as git resolves it. Reporting "ignored"
|
|
315
|
+
* for a file git will happily commit is the false all-clear this repo keeps
|
|
316
|
+
* relearning, so the last matching rule decides.
|
|
317
|
+
*/
|
|
318
|
+
export function gitignoreCoversAcuvo(text) {
|
|
319
|
+
if (typeof text !== 'string' || text.trim() === '') return false;
|
|
320
|
+
let covered = false;
|
|
321
|
+
// A BOM in front of the first pattern makes it match nothing — strip it.
|
|
322
|
+
for (const raw of text.replace(/^/, '').split(/\r?\n/)) {
|
|
323
|
+
const line = raw.trim();
|
|
324
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
325
|
+
const negated = line.startsWith('!');
|
|
326
|
+
const pattern = (negated ? line.slice(1) : line).replace(/^\/+/, '').replace(/\/+$/, '');
|
|
327
|
+
// `.acuvo`, `.acuvo/`, `/.acuvo/` and `.acuvo/**` all cover the directory.
|
|
328
|
+
// `.acuvo/skills` covers only a child, and `.acuvo.md` is a different name.
|
|
329
|
+
if (pattern === ACUVO_DIR || pattern === `${ACUVO_DIR}/**` || pattern === `${ACUVO_DIR}/*`) {
|
|
330
|
+
covered = !negated;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return covered;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
337
|
+
// PURE: SECRETS
|
|
338
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
const REDACTED = '<redacted>';
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* ⚠️ TWO MECHANISMS, BECAUSE ONE IS NOT ENOUGH.
|
|
344
|
+
*
|
|
345
|
+
* 1. VALUE MATCHING catches a credential we ourselves put in a string.
|
|
346
|
+
* 2. SHAPE MATCHING catches one we never held — specifically OpenRouter's
|
|
347
|
+
* `label` field, `sk-or-v1-abc...xyz`, which is NOT a substring of the key
|
|
348
|
+
* and which a value-only scrub sails straight past. It is still a prefix
|
|
349
|
+
* and a suffix of a live credential.
|
|
350
|
+
*
|
|
351
|
+
* ⚠️ AN EMPTY OR WHITESPACE ENV VALUE IS NEVER A PATTERN. `''` as a search
|
|
352
|
+
* string replaces between every character; a doctor that redacted every
|
|
353
|
+
* character of its own report would be a very thorough kind of useless.
|
|
354
|
+
*/
|
|
355
|
+
export function scrubSecrets(value, env = {}, names = SECRET_ENV_VARS) {
|
|
356
|
+
const literals = [];
|
|
357
|
+
for (const name of names) {
|
|
358
|
+
const v = env?.[name];
|
|
359
|
+
if (typeof v === 'string' && v.trim().length >= 8) literals.push(v.trim());
|
|
360
|
+
}
|
|
361
|
+
// Longest first, so a key that contains another value is not half-replaced.
|
|
362
|
+
literals.sort((a, b) => b.length - a.length);
|
|
363
|
+
|
|
364
|
+
const scrubString = (s) => {
|
|
365
|
+
let out = s;
|
|
366
|
+
for (const lit of literals) out = out.split(lit).join(REDACTED);
|
|
367
|
+
return out
|
|
368
|
+
// Whole credential-bearing header lines, value and all.
|
|
369
|
+
.replace(/^[ \t]*(authorization|proxy-authorization|x-api-key|api-key)[ \t]*:.*$/gim, `<header ${REDACTED}>`)
|
|
370
|
+
// OpenRouter's own shape, dots included so the `abc...xyz` label dies too.
|
|
371
|
+
.replace(/sk-or-v1-[A-Za-z0-9._-]+/g, REDACTED)
|
|
372
|
+
// Every other provider's key, loose on purpose: a false positive costs a
|
|
373
|
+
// reader nothing, a false negative costs them a credential.
|
|
374
|
+
.replace(/sk-[A-Za-z0-9_-]{16,}/g, REDACTED);
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const walk = (v) => {
|
|
378
|
+
if (typeof v === 'string') return scrubString(v);
|
|
379
|
+
if (Array.isArray(v)) return v.map(walk);
|
|
380
|
+
if (v && typeof v === 'object') {
|
|
381
|
+
const out = {};
|
|
382
|
+
for (const [k, val] of Object.entries(v)) out[k] = walk(val);
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
return v;
|
|
386
|
+
};
|
|
387
|
+
return walk(value);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Host only — a path or a query string can carry a token, and a host cannot. */
|
|
391
|
+
function hostOf(url) {
|
|
392
|
+
try { return new URL(String(url)).host || null; } catch { return null; }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
396
|
+
// PURE: THE TOOL OFFER
|
|
397
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
/** Tools that spawn a process, run a command, or write history — all ride with --no-run. */
|
|
400
|
+
/**
|
|
401
|
+
* ⚠️ THE BACKGROUND THREE RIDE WITH `allowRun` TOO. This set has to name every
|
|
402
|
+
* tool `toolNamesForRounds` withholds under `--no-run`, or the doctor reports a
|
|
403
|
+
* tool as missing and cannot say why — which is the one thing it exists to do.
|
|
404
|
+
*/
|
|
405
|
+
const RUN_GATED = new Set([
|
|
406
|
+
'run_command', 'run_program', 'evaluate', 'git_commit', 'declare_acceptance', 'check_acceptance',
|
|
407
|
+
// ⚠️ The delivery half rides with `allowRun` too — `git_branch` writes a ref
|
|
408
|
+
// and `git_push` writes to a remote, and the drift guard in doctor.test.mjs
|
|
409
|
+
// ("every tool --no-run withholds can be EXPLAINED") is what would catch this
|
|
410
|
+
// list going stale again, as it already did for the background three.
|
|
411
|
+
'git_branch', 'git_push',
|
|
412
|
+
'start_process', 'check_process', 'stop_process',
|
|
413
|
+
// ⚠️ The REPL executes the user's JavaScript, so --no-run withholds it too —
|
|
414
|
+
// caught by the drift guard the hour it shipped, which is the point of it.
|
|
415
|
+
'repl', 'repl_reset',
|
|
416
|
+
/**
|
|
417
|
+
* ⚠️ `call_endpoint` RIDES WITH `allowRun` BECAUSE ITS TARGET DOES. It can only
|
|
418
|
+
* reach a port registered by `start_process`, so under `--no-run` there is
|
|
419
|
+
* nothing for it to call and offering it would be a dead button.
|
|
420
|
+
*
|
|
421
|
+
* ⭐ Caught by this same drift guard on the day it shipped — the third time
|
|
422
|
+
* that has happened (the background three, then the REPL pair, now this).
|
|
423
|
+
* ⚠️ Without the entry the doctor reported it dark as "not offered in this
|
|
424
|
+
* configuration", which is true and useless: it names no cause and no fix, and
|
|
425
|
+
* that is precisely the failure this file exists to prevent.
|
|
426
|
+
*/
|
|
427
|
+
'call_endpoint',
|
|
428
|
+
/**
|
|
429
|
+
* ⚠️ THE SIX THAT ARRIVED WITH THE DARK-MODULE WIRING (2026-08-17). gh spawns
|
|
430
|
+
* the `gh` binary; the three log verbs can only read a process `start_process`
|
|
431
|
+
* launched, which `--no-run` refuses. Fourth time this drift guard has caught
|
|
432
|
+
* a missing entry the same day a tool shipped — which is the entire argument
|
|
433
|
+
* for deriving the list from the gating function rather than typing it out.
|
|
434
|
+
*/
|
|
435
|
+
'gh_issue', 'gh_pr', 'gh_run',
|
|
436
|
+
'read_log', 'wait_for_output', 'summarize_log',
|
|
437
|
+
]);
|
|
438
|
+
/** media tool -> the variable that turns it on. */
|
|
439
|
+
const MEDIA_ENV = {
|
|
440
|
+
see_page: 'RENDER_AUDIT_URL',
|
|
441
|
+
speak: 'MODAL_TTS_URL',
|
|
442
|
+
transcribe: 'MODAL_TRANSCRIBE_URL',
|
|
443
|
+
make_document: 'MODAL_PRESS_URL',
|
|
444
|
+
read_document: 'MODAL_DOC_READ_URL',
|
|
445
|
+
read_table: 'MODAL_TABLE_READ_URL',
|
|
446
|
+
// ⚠️ edit_image needs TWO services and this map holds one name. MODAL_SELECT_URL
|
|
447
|
+
// is the one without which the verb is impossible (no mask, no inpaint), so it
|
|
448
|
+
// is the one a person should be told to set. expand_image needs only flux.
|
|
449
|
+
edit_image: 'MODAL_SELECT_URL',
|
|
450
|
+
expand_image: 'MODAL_FLUX_URL',
|
|
451
|
+
};
|
|
452
|
+
/**
|
|
453
|
+
* ⭐ The media tools whose endpoint URL is BAKED IN by `media.mjs` and therefore
|
|
454
|
+
* blocked only by the credential. Kept beside MEDIA_ENV so nobody edits one
|
|
455
|
+
* without seeing the other.
|
|
456
|
+
*/
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* ── ⭐⭐ "IT IS DARK" IS HALF AN ANSWER WHEN THE CREDENTIAL IS 40cm AWAY ─────
|
|
460
|
+
*
|
|
461
|
+
* Measured 2026-08-12: all six media capabilities were dark on this machine, and
|
|
462
|
+
* every one of them WORKED — `see_page` rendered a page and caught a real
|
|
463
|
+
* 1.15:1 contrast failure in 5 seconds — the moment the secret was loaded. The
|
|
464
|
+
* secret was sitting in a SIBLING project's `.env.local` the whole time.
|
|
465
|
+
*
|
|
466
|
+
* The env walk deliberately stops at the repository root, so it cannot reach a
|
|
467
|
+
* sibling, and it should not: silently crossing into another checkout to find
|
|
468
|
+
* credentials is exactly the behaviour a security reviewer would object to.
|
|
469
|
+
*
|
|
470
|
+
* ⭐ SO THE DOCTOR LOOKS, AND ONLY TELLS. It reports the PATH and the variable
|
|
471
|
+
* NAME — never the value — and leaves the copying to a person. That turns "this
|
|
472
|
+
* is dark" into "this is dark, and the thing that fixes it is in that file",
|
|
473
|
+
* which is the difference between a diagnosis and a dead end.
|
|
474
|
+
*
|
|
475
|
+
* ⚠️ BOUNDED AND READ-ONLY: siblings of the workspace and its parent, one level,
|
|
476
|
+
* `.env.local`/`.env` only, and it never reads a value out.
|
|
477
|
+
*/
|
|
478
|
+
/**
|
|
479
|
+
* ⚠️⚠️ MEMOISED, AND THE FIRST VERSION WAS NOT — IT COST 36 SECONDS.
|
|
480
|
+
*
|
|
481
|
+
* `toolOffer` asks this question once per withheld media tool, and there are six
|
|
482
|
+
* of them. Each ask walked the workspace's parent (sixty-odd sibling projects on
|
|
483
|
+
* this machine), then stat-ed and read two candidate files in every one. A
|
|
484
|
+
* doctor that takes half a minute is a doctor nobody runs — and the end-to-end
|
|
485
|
+
* test that asserts "the MCP section must not spawn or WAIT on anything" caught
|
|
486
|
+
* it, which is exactly the kind of thing that assertion is for.
|
|
487
|
+
*
|
|
488
|
+
* The answer cannot change during one process, so it is computed once per
|
|
489
|
+
* (root, names) and reused.
|
|
490
|
+
*/
|
|
491
|
+
const credentialCache = new Map();
|
|
492
|
+
|
|
493
|
+
export function findCredentialNearby(varNames, { root = process.cwd(), readdirImpl = readdirSync, readImpl = readFileSync, existsImpl = existsSync } = {}) {
|
|
494
|
+
const names = Array.isArray(varNames) ? varNames : [varNames];
|
|
495
|
+
const cacheKey = `${resolve(root)}::${names.join(',')}`;
|
|
496
|
+
if (credentialCache.has(cacheKey)) return credentialCache.get(cacheKey);
|
|
497
|
+
const answer = scanForCredential(names, { root, readdirImpl, readImpl, existsImpl });
|
|
498
|
+
credentialCache.set(cacheKey, answer);
|
|
499
|
+
return answer;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** For tests: the cache must not leak an answer from one temp workspace to another. */
|
|
503
|
+
export function clearCredentialCache() {
|
|
504
|
+
credentialCache.clear();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* ⚠️⚠️ BOUNDED, BECAUSE AN UNBOUNDED WALK IS NOT A CONVENIENCE, IT IS A HAZARD.
|
|
509
|
+
*
|
|
510
|
+
* Measured: a workspace whose parent is `%TEMP%` — which after a day of testing
|
|
511
|
+
* held several hundred directories — took **6.8 seconds** to answer, tripping
|
|
512
|
+
* the end-to-end assertion that the doctor must never WAIT on anything. In a
|
|
513
|
+
* user's home or a monorepo root the same shape exists.
|
|
514
|
+
*
|
|
515
|
+
* This is a helpfulness feature: "the credential you need is in that file". It
|
|
516
|
+
* is worth a few milliseconds and not worth a directory crawl, so it looks at a
|
|
517
|
+
* bounded number of neighbours and stops. Missing a credential buried in the
|
|
518
|
+
* 41st sibling costs one line of advice; scanning forever costs the tool.
|
|
519
|
+
*/
|
|
520
|
+
const MAX_NEIGHBOUR_DIRS = 40;
|
|
521
|
+
|
|
522
|
+
function scanForCredential(varNames, { root, readdirImpl, readImpl, existsImpl }) {
|
|
523
|
+
const seen = new Set();
|
|
524
|
+
const dirs = [];
|
|
525
|
+
try {
|
|
526
|
+
const parent = dirname(resolve(root));
|
|
527
|
+
for (const base of [resolve(root), parent]) {
|
|
528
|
+
dirs.push(base);
|
|
529
|
+
try {
|
|
530
|
+
for (const entry of readdirImpl(base, { withFileTypes: true })) {
|
|
531
|
+
if (dirs.length >= MAX_NEIGHBOUR_DIRS) break;
|
|
532
|
+
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
533
|
+
dirs.push(join(base, entry.name));
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
} catch { /* unreadable level */ }
|
|
537
|
+
}
|
|
538
|
+
} catch { return null; }
|
|
539
|
+
|
|
540
|
+
for (const dir of dirs) {
|
|
541
|
+
for (const name of ['.env.local', '.env']) {
|
|
542
|
+
const file = join(dir, name);
|
|
543
|
+
if (seen.has(file)) continue;
|
|
544
|
+
seen.add(file);
|
|
545
|
+
try {
|
|
546
|
+
if (!existsImpl(file)) continue;
|
|
547
|
+
const text = readImpl(file, 'utf8');
|
|
548
|
+
// ⚠️ The NAME only. A doctor that prints a secret is a worse problem
|
|
549
|
+
// than the one it was diagnosing.
|
|
550
|
+
/**
|
|
551
|
+
* ⚠️ A LINE SCAN, NOT A REGEX. The pattern needed four levels of
|
|
552
|
+
* escaping to survive the tooling that wrote this file, and the version
|
|
553
|
+
* that shipped compiled to `^s*NAME s*=` — matching nothing, silently.
|
|
554
|
+
* A check that quietly matches nothing is the check-that-cannot-fail
|
|
555
|
+
* again, and this one is one line of string work.
|
|
556
|
+
*/
|
|
557
|
+
/**
|
|
558
|
+
* ⚠️⚠️ EVERY ACCEPTED NAME, NOT ONE — AND THIS IS THE FOURTH TIME TODAY
|
|
559
|
+
* THAT NAMING ANOTHER MODULE'S STRINGS WAS A GUESS. The first version
|
|
560
|
+
* searched only `ACUVO_MEDIA_SECRET` and reported "not found anywhere"
|
|
561
|
+
* while the credential sat in the very file it had just read, under
|
|
562
|
+
* `MODAL_VIDEO_SECRET` — the OTHER name `mediaConfig` accepts. The list
|
|
563
|
+
* is imported from `media.mjs`, so the two cannot disagree again.
|
|
564
|
+
*/
|
|
565
|
+
const names = Array.isArray(varNames) ? varNames : [varNames];
|
|
566
|
+
let hit = null;
|
|
567
|
+
for (const line of text.split('\n')) {
|
|
568
|
+
const t = line.trim();
|
|
569
|
+
const name = names.find((n) => t.startsWith(`${n}=`));
|
|
570
|
+
if (!name) continue;
|
|
571
|
+
// ⚠️ An empty value is a deliberate OFF, not a credential to copy.
|
|
572
|
+
if (t.slice(name.length + 1).trim() === '') continue;
|
|
573
|
+
hit = name;
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* ⚠️ IT RETURNS THE NAME IT ACTUALLY FOUND, not the one asked for first.
|
|
578
|
+
* The message read "ACUVO_MEDIA_SECRET is already in <file>" about a file
|
|
579
|
+
* containing `MODAL_VIDEO_SECRET` — advice that sends somebody looking
|
|
580
|
+
* for a line that is not there.
|
|
581
|
+
*/
|
|
582
|
+
if (hit) return { file, name: hit };
|
|
583
|
+
} catch { /* unreadable — not a finding */ }
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const MEDIA_BUILT_IN = new Set(['speak', 'transcribe', 'make_document', 'read_document', 'read_table']);
|
|
590
|
+
const LSP_TOOLS = new Set(['find_definition', 'find_references', 'check_types', 'list_symbols']);
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* ── ⭐⭐ WHICH TOOLS WOULD ACTUALLY BE OFFERED HERE, AND WHY THE REST WOULD NOT
|
|
594
|
+
*
|
|
595
|
+
* ⚠️ IT ASKS THE REAL FUNCTION. `toolNamesForRounds` is the code the turn loop
|
|
596
|
+
* runs, so this cannot drift from what the model is really shown — a doctor
|
|
597
|
+
* with its own copy of the gating rules is a doctor that eventually lies. That
|
|
598
|
+
* is the same class of bug as a capability that exists and is never imported.
|
|
599
|
+
*
|
|
600
|
+
* ⭐ AND THE REASON IS THE PRODUCT. "read_skill: withheld" is worthless.
|
|
601
|
+
* "read_skill: withheld — no .acuvo/skills directory with a skill in it" is the
|
|
602
|
+
* end of the question.
|
|
603
|
+
*/
|
|
604
|
+
export function toolOffer({ root = process.cwd(), env = process.env, allowRun = true, maxRounds = 8 } = {}) {
|
|
605
|
+
let offered = [];
|
|
606
|
+
let failed = null;
|
|
607
|
+
try {
|
|
608
|
+
offered = toolNamesForRounds(maxRounds, { allowRun, env, root });
|
|
609
|
+
} catch (err) {
|
|
610
|
+
failed = err instanceof Error ? err.message : String(err);
|
|
611
|
+
offered = [];
|
|
612
|
+
}
|
|
613
|
+
const have = new Set(offered);
|
|
614
|
+
|
|
615
|
+
let languages = new Set();
|
|
616
|
+
try { languages = languagesPresent(root); } catch { languages = new Set(); }
|
|
617
|
+
|
|
618
|
+
const withheld = [];
|
|
619
|
+
for (const name of TOOL_NAMES) {
|
|
620
|
+
if (have.has(name)) continue;
|
|
621
|
+
withheld.push({ name, ...whyWithheld(name, { env, allowRun, maxRounds, languages, failed, root }) });
|
|
622
|
+
}
|
|
623
|
+
return { offered, withheld, total: TOOL_NAMES.length };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function whyWithheld(name, { env, allowRun, maxRounds, languages, failed, root = process.cwd() }) {
|
|
627
|
+
if (failed) {
|
|
628
|
+
return { why: `the offer could not be computed here — ${failed}`, fix: 'run the doctor from a readable workspace directory' };
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* ⚠️ THE ROUND BUDGET IS CHECKED FIRST, because in a single-shot run nearly
|
|
632
|
+
* everything is withheld and blaming configuration for it would send the
|
|
633
|
+
* reader hunting for an env var that is already set correctly.
|
|
634
|
+
*/
|
|
635
|
+
if (maxRounds <= 1 && !MEDIA_ENV[name] && name !== 'generate_image') {
|
|
636
|
+
return {
|
|
637
|
+
why: 'this is a single-shot run: a read or a command has no round to come back in, so offering it would be a dead button',
|
|
638
|
+
fix: 'raise the round budget (--max-rounds N) so results have somewhere to go',
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
if (!allowRun && RUN_GATED.has(name)) {
|
|
642
|
+
return { why: '--no-run was passed, and this tool executes something', fix: 'drop --no-run to allow it' };
|
|
643
|
+
}
|
|
644
|
+
if (MEDIA_ENV[name]) {
|
|
645
|
+
/**
|
|
646
|
+
* ⚠️ SAME CORRECTION AS THE MEDIA SECTION, AND IT HAD TO BE MADE TWICE
|
|
647
|
+
* BECAUSE THE ADVICE LIVES IN TWO PLACES. `media.mjs` bakes a default URL in
|
|
648
|
+
* for these, so the reader does not need an endpoint URL they cannot guess —
|
|
649
|
+
* they need ONE credential. Telling them otherwise is wrong advice in a
|
|
650
|
+
* diagnostic, which is worse than none because it gets followed.
|
|
651
|
+
*
|
|
652
|
+
* `see_page` and the two image services have NO baked default, so for them
|
|
653
|
+
* the variable really is the thing to set.
|
|
654
|
+
*/
|
|
655
|
+
if (MEDIA_BUILT_IN.has(name)) {
|
|
656
|
+
const found = findCredentialNearby(MEDIA_SECRET_ENV_NAMES, { root });
|
|
657
|
+
return {
|
|
658
|
+
why: 'no media credential is set, so the service is not configured here (its URL is built in)',
|
|
659
|
+
fix: found
|
|
660
|
+
? `${found.name} is already set in ${found.file} — copy that line into this workspace's .env.local. `
|
|
661
|
+
+ 'The env walk stops at the repository root on purpose, so it will not reach across to another checkout for you.'
|
|
662
|
+
: 'set ACUVO_MEDIA_SECRET in .env.local — one credential turns on speech, transcription, documents and table reading together',
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
const alt = name === 'see_page' ? ' (or MODAL_RENDER_AUDIT_URL)' : '';
|
|
666
|
+
return {
|
|
667
|
+
why: `${MEDIA_ENV[name]} is unset${alt}, so the service does not exist here`,
|
|
668
|
+
fix: `set ${MEDIA_ENV[name]} to your endpoint URL, and ${'MODAL_VIDEO_SECRET'} to the value it expects`,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
if (name === 'generate_image') {
|
|
672
|
+
return {
|
|
673
|
+
why: `${IMAGE_URL_ENV} is set to an empty value, which means the image service is deliberately OFF`,
|
|
674
|
+
fix: `unset ${IMAGE_URL_ENV} to use the default endpoint, or set it to your own image service`,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* ── ⚠️ THIS REASON WAS TRUE AND STOPPED BEING TRUE ─────────────────────────
|
|
679
|
+
*
|
|
680
|
+
* It said "no `.acuvo/skills` directory with at least one readable skill in
|
|
681
|
+
* it", which was the whole story while the only skills were a project's own.
|
|
682
|
+
* Skills SHIP with the CLI now, so a bare workspace has a full shelf and
|
|
683
|
+
* `read_skill` is never withheld for a missing directory — measured: on a
|
|
684
|
+
* fresh temp dir it is OFFERED, and this branch could only fire if the
|
|
685
|
+
* BUNDLED shelf were empty.
|
|
686
|
+
*
|
|
687
|
+
* ⭐ A withheld-reason that names the wrong cause is worse than a generic one,
|
|
688
|
+
* because it sends somebody to create a directory that would change nothing.
|
|
689
|
+
* The message now names both sources, so whichever one is actually missing,
|
|
690
|
+
* the sentence is true.
|
|
691
|
+
*/
|
|
692
|
+
if (name === 'read_skill') {
|
|
693
|
+
return {
|
|
694
|
+
why: `no skills are readable at all — the bundled shelf is empty AND there is no ${ACUVO_DIR}/skills directory with a readable skill in it`,
|
|
695
|
+
fix: `reinstall acuvo-code to restore the bundled skills, or create ${ACUVO_DIR}/skills/<name>.md with name/description frontmatter`,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* ⭐ THE REASON IS THE PRODUCT, and "not offered in this configuration" (the
|
|
700
|
+
* fallback at the bottom of this function) is exactly the non-answer this
|
|
701
|
+
* doctor exists to avoid. Push is dark on every machine by default, so it
|
|
702
|
+
* would be the most frequently non-answered tool in the list.
|
|
703
|
+
*/
|
|
704
|
+
if (name === 'git_push') {
|
|
705
|
+
return {
|
|
706
|
+
why: `${ALLOW_PUSH_ENV} is not set, so pushing is off — the one verb that leaves this machine is opt-in`,
|
|
707
|
+
fix: `set ${ALLOW_PUSH_ENV}=1 to allow it. It still refuses protected branches (${PROTECTED_BRANCHES.slice(0, 4).join(', ')}, …) and never force-pushes.`,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
if (LSP_TOOLS.has(name)) {
|
|
711
|
+
const langs = [...languages];
|
|
712
|
+
if (langs.length === 0) {
|
|
713
|
+
return {
|
|
714
|
+
why: 'no recognised language manifest or source file was found near the top of this workspace, so no language server could answer here',
|
|
715
|
+
fix: 'run the doctor from the project root (a package.json / pyproject.toml / Cargo.toml / go.mod)',
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
const installs = langs.map((l) => LANGUAGE_SERVERS[l]?.install).filter(Boolean);
|
|
719
|
+
return {
|
|
720
|
+
why: `no language server is installed for ${langs.join(', ')} — semantic navigation cannot answer here`,
|
|
721
|
+
fix: installs.length ? `install one: ${installs.join(' · ')}` : 'install a language server for this project',
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
return { why: 'not offered in this configuration', fix: 'run the doctor again with the flags you actually use' };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
728
|
+
// PURE: MCP — EVERYTHING KNOWABLE WITHOUT STARTING A PROGRAM
|
|
729
|
+
//
|
|
730
|
+
// ── ⚠️⚠️ THE DOCTOR MUST NEVER SPAWN AN MCP SERVER ──────────────────────────
|
|
731
|
+
//
|
|
732
|
+
// It is the one probe in this file that would be genuinely dangerous to run.
|
|
733
|
+
// Every other check costs an HTTP round trip we bound twice; starting an MCP
|
|
734
|
+
// server costs a PROCESS — and measured on this network today, an `npx`-based
|
|
735
|
+
// server hangs for minutes on a cold package cache. Eight of those, at
|
|
736
|
+
// `HANDSHAKE_TIMEOUT_MS = 20s` each, is a diagnostic nobody runs twice. It also
|
|
737
|
+
// has side effects: MCP servers open sockets, touch remote systems and are
|
|
738
|
+
// handed the user's real tokens (`mcp.mjs` passes credentials deliberately).
|
|
739
|
+
//
|
|
740
|
+
// ⭐ SO THIS SECTION ANSWERS THE CHEAP QUESTIONS AND SAYS "not checked" TO THE
|
|
741
|
+
// REST — the `verified: false` convention this file already uses for a probe it
|
|
742
|
+
// deliberately did not make. The cheap questions turn out to be the ones that
|
|
743
|
+
// actually fail:
|
|
744
|
+
//
|
|
745
|
+
// · is a server declared at all, and in which file
|
|
746
|
+
// · does its command RESOLVE — the #1 failure, and free to answer
|
|
747
|
+
// · does its env block break the credential rather than supply it
|
|
748
|
+
//
|
|
749
|
+
// ── ⚠️ THIS FILE MODELS HOW mcp.mjs STARTS A SERVER, AND THAT IS A COUPLING ──
|
|
750
|
+
// `resolveExecutable` and `nodeCliEntry` are not exported there, so the rules
|
|
751
|
+
// are mirrored here rather than asked for — the one thing this file otherwise
|
|
752
|
+
// refuses to do (see `toolOffer`, which calls the real gate). Two tests in
|
|
753
|
+
// doctor.test.mjs import `readMcpConfig` and `connectServer` and pin the
|
|
754
|
+
// behaviours this mirror depends on, so a change there fails a test rather than
|
|
755
|
+
// silently turning this section into fiction.
|
|
756
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* ⚠️ ONE HONEST LIMIT ON THE "NEVER HANGS" PROMISE. This section is the only
|
|
760
|
+
* one that touches the filesystem SYNCHRONOUSLY (`existsSync`/`statSync` while
|
|
761
|
+
* walking PATH, and `readMcpConfig`'s own `readFileSync`), and a sync call
|
|
762
|
+
* cannot be raced by `withTimeout`. A PATH entry pointing at a dead network
|
|
763
|
+
* share can therefore stall it — the same exposure `mcp.mjs` and `command.mjs`
|
|
764
|
+
* already carry at spawn time, and the walk stops at the first hit. Bounding it
|
|
765
|
+
* properly would mean a worker thread, which is a large amount of machinery to
|
|
766
|
+
* add to a diagnostic; recorded here rather than quietly assumed away.
|
|
767
|
+
*/
|
|
768
|
+
|
|
769
|
+
/** `${VAR}` / `$VAR` — a reference the user THINKS will be expanded. */
|
|
770
|
+
const ENV_PLACEHOLDER = /^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/;
|
|
771
|
+
|
|
772
|
+
export function envPlaceholderName(value) {
|
|
773
|
+
const m = ENV_PLACEHOLDER.exec(String(value ?? '').trim());
|
|
774
|
+
return m ? m[1] : null;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Where `mcp.mjs` would find this command — or why it would not.
|
|
779
|
+
*
|
|
780
|
+
* kinds: `path` · `node-entry` (npm/npx, run through node's own CLI entry) ·
|
|
781
|
+
* `missing` · `missing-path` · `missing-npm` · `unknown` (no PATH to search).
|
|
782
|
+
*
|
|
783
|
+
* ⚠️ `unknown` EXISTS SO AN ODD ENVIRONMENT IS NOT CALLED BROKEN. A container
|
|
784
|
+
* with no PATH set is not proof that a binary is absent, and painting it red
|
|
785
|
+
* would be this repo's most-repeated mistake: a check that fails correct work.
|
|
786
|
+
*/
|
|
787
|
+
export function resolveMcpCommand(command, {
|
|
788
|
+
env = process.env,
|
|
789
|
+
platform = process.platform,
|
|
790
|
+
existsImpl = existsSync,
|
|
791
|
+
statImpl = statSync,
|
|
792
|
+
execPath = process.execPath,
|
|
793
|
+
} = {}) {
|
|
794
|
+
const cmd = String(command ?? '').trim();
|
|
795
|
+
if (!cmd) return { kind: 'missing', path: null };
|
|
796
|
+
|
|
797
|
+
const exists = (p) => { try { return !!existsImpl(p); } catch { return false; } };
|
|
798
|
+
const isFile = (p) => { try { return exists(p) && statImpl(p).isFile(); } catch { return false; } };
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* ⭐ npm/npx NEVER RESOLVE ON PATH HERE, and that is not an optimisation.
|
|
802
|
+
* `mcp.mjs` spawns them as `node <npm's own npx-cli.js>` because Windows
|
|
803
|
+
* refuses to run a `.cmd` without a shell (CVE-2024-27980) and a shell is the
|
|
804
|
+
* injection surface the whole package exists to avoid. Reporting "npx found
|
|
805
|
+
* at C:\…\npx.cmd" would name a file that is never executed.
|
|
806
|
+
*/
|
|
807
|
+
if (cmd === 'npm' || cmd === 'npx') {
|
|
808
|
+
const dir = dirname(execPath);
|
|
809
|
+
const file = cmd === 'npm' ? 'npm-cli.js' : 'npx-cli.js';
|
|
810
|
+
for (const c of [
|
|
811
|
+
join(dir, 'node_modules', 'npm', 'bin', file),
|
|
812
|
+
join(dir, '..', 'lib', 'node_modules', 'npm', 'bin', file),
|
|
813
|
+
join(dir, '..', 'node_modules', 'npm', 'bin', file),
|
|
814
|
+
]) {
|
|
815
|
+
if (exists(c)) return { kind: 'node-entry', path: c };
|
|
816
|
+
}
|
|
817
|
+
return { kind: 'missing-npm', path: null };
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (cmd.includes('/') || cmd.includes(String.fromCharCode(92))) {
|
|
821
|
+
return exists(cmd) ? { kind: 'path', path: cmd } : { kind: 'missing-path', path: cmd };
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const sep = platform === 'win32' ? ';' : ':';
|
|
825
|
+
const dirs = String(env?.PATH || env?.Path || '').split(sep).filter(Boolean);
|
|
826
|
+
if (dirs.length === 0) return { kind: 'unknown', path: null };
|
|
827
|
+
/**
|
|
828
|
+
* ⚠️ EXTENSIONS BEFORE THE BARE NAME, the order `mcp.mjs` had to fix: the
|
|
829
|
+
* nodejs directory holds BOTH `npx` (a bash script Windows cannot spawn) and
|
|
830
|
+
* `npx.cmd`, and finding the bare name first reports a path that exists and
|
|
831
|
+
* still fails to start.
|
|
832
|
+
*/
|
|
833
|
+
const exts = platform === 'win32'
|
|
834
|
+
? [...String(env?.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), '']
|
|
835
|
+
: [''];
|
|
836
|
+
for (const d of dirs) {
|
|
837
|
+
for (const ext of exts) {
|
|
838
|
+
const candidate = join(d, cmd + ext);
|
|
839
|
+
if (isFile(candidate)) return { kind: 'path', path: candidate };
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return { kind: 'missing', path: null };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* ── ⭐⭐ THE CREDENTIAL BUG THAT IS INVISIBLE FROM THE CONFIG FILE ───────────
|
|
847
|
+
*
|
|
848
|
+
* `connectServer` builds the child environment as `{...process.env, ...server.env}`
|
|
849
|
+
* and expands NOTHING. So the two shapes people write most often are both
|
|
850
|
+
* actively harmful rather than merely useless:
|
|
851
|
+
*
|
|
852
|
+
* "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" } the server receives the
|
|
853
|
+
* fifteen literal characters — AND the real token it would have
|
|
854
|
+
* inherited is overwritten by them.
|
|
855
|
+
* "env": { "GITHUB_TOKEN": "" } the real token is
|
|
856
|
+
* overwritten with nothing.
|
|
857
|
+
*
|
|
858
|
+
* ⚠️ NOTE WHAT THIS MEANS FOR A NAIVE CHECK: asking "is GITHUB_TOKEN absent
|
|
859
|
+
* from the environment" gives the placeholder case a clean bill of health,
|
|
860
|
+
* because the variable IS set — it is just not what the server will get.
|
|
861
|
+
*
|
|
862
|
+
* Returns `{ key, kind, ref }`. Never the value: a config entry can hold a real
|
|
863
|
+
* credential, and this file does not print those.
|
|
864
|
+
*/
|
|
865
|
+
export function mcpCredentialGaps(serverEnv, env = {}) {
|
|
866
|
+
const gaps = [];
|
|
867
|
+
for (const [key, raw] of Object.entries(serverEnv ?? {})) {
|
|
868
|
+
const value = typeof raw === 'string' ? raw : '';
|
|
869
|
+
if (value.trim() === '') { gaps.push({ key, kind: 'empty', ref: null }); continue; }
|
|
870
|
+
const ref = envPlaceholderName(value);
|
|
871
|
+
if (ref) gaps.push({ key, kind: 'placeholder', ref });
|
|
872
|
+
}
|
|
873
|
+
return gaps;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** One declared server, judged. Nothing here starts anything. */
|
|
877
|
+
export function assessMcpServer(server, { file = null, env = {}, resolution = { kind: 'unknown', path: null } } = {}) {
|
|
878
|
+
const base = { id: `mcp.${server?.name}`, label: String(server?.name ?? '') };
|
|
879
|
+
const where = `declared in ${file ?? 'the MCP config'}`;
|
|
880
|
+
const cmd = String(server?.command ?? '');
|
|
881
|
+
const fixFile = file ?? MCP_CONFIG_FILES[0];
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* ── ⚠️⚠️ A HOSTED SERVER HAS NO COMMAND, AND THIS SECTION WOULD CALL IT
|
|
885
|
+
* BROKEN ────────────────────────────────────────────────────────────
|
|
886
|
+
*
|
|
887
|
+
* Added 2026-08-15 with remote transports in `mcp.mjs`. Everything below this
|
|
888
|
+
* point asks "where would `mcp.mjs` find this executable" — a question with no
|
|
889
|
+
* answer for `{"type":"http","url":"https://mcp.sentry.dev/mcp"}`, because
|
|
890
|
+
* nothing is executed. Without this branch `resolveMcpCommand` reports
|
|
891
|
+
* `missing-path` for the URL and the doctor prints
|
|
892
|
+
* ✖ the command "https://mcp.sentry.dev/mcp" does not exist
|
|
893
|
+
* for a server that works perfectly — a check that fails correct work, which
|
|
894
|
+
* this repo has paid for four times in one day and which is worse than no
|
|
895
|
+
* check because it gets acted on.
|
|
896
|
+
*
|
|
897
|
+
* ⚠️ IT STAYS `verified: false`. The doctor never connects, so "declared, and
|
|
898
|
+
* the URL is well-formed" is the entire claim — exactly the honesty convention
|
|
899
|
+
* the branch below it uses for a command that resolves.
|
|
900
|
+
*/
|
|
901
|
+
if (server?.transport === 'http' || server?.transport === 'sse') {
|
|
902
|
+
let host = String(server?.url ?? '');
|
|
903
|
+
try { host = new URL(server.url).host; } catch { /* readMcpConfig already validated it */ }
|
|
904
|
+
const refs = [...new Set(
|
|
905
|
+
Object.values(server?.headers ?? {})
|
|
906
|
+
.flatMap((v) => [...String(v).matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g)])
|
|
907
|
+
.map((m) => m[1] ?? m[2]),
|
|
908
|
+
)];
|
|
909
|
+
const unset = refs.filter((n) => typeof env?.[n] !== 'string' || env[n].trim() === '');
|
|
910
|
+
if (unset.length > 0) {
|
|
911
|
+
return {
|
|
912
|
+
...base,
|
|
913
|
+
state: 'broken',
|
|
914
|
+
verified: true,
|
|
915
|
+
detail: `${where} · reached over ${server.transport === 'sse' ? 'SSE' : 'HTTP'} at ${host}, and its headers reference ${unset.join(', ')}, which ${unset.length === 1 ? 'is' : 'are'} not set — this client refuses to connect rather than send the literal "\${${unset[0]}}" to a third party`,
|
|
916
|
+
fix: `export ${unset.join(', ')} in your shell (the value is read at connect time and never written into ${fixFile})`,
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
return {
|
|
920
|
+
...base,
|
|
921
|
+
state: 'live',
|
|
922
|
+
verified: false,
|
|
923
|
+
detail: `${where} · reached over ${server.transport === 'sse' ? 'SSE' : 'HTTP'} at ${host} · nothing is spawned${refs.length ? `, and your ${refs.join(', ')} travels with every call` : ''} · not checked — the doctor never connects to an MCP server`,
|
|
924
|
+
fix: null,
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (resolution.kind === 'missing' || resolution.kind === 'missing-path') {
|
|
929
|
+
return {
|
|
930
|
+
...base,
|
|
931
|
+
state: 'broken',
|
|
932
|
+
verified: true,
|
|
933
|
+
detail: `${where} · the command "${cmd}" ${resolution.kind === 'missing-path' ? 'does not exist' : 'was not found on PATH'}, so this server cannot start and none of its tools are offered`,
|
|
934
|
+
fix: `install it, or set "${server?.name}".command in ${fixFile} to a full path to the executable`,
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
if (resolution.kind === 'missing-npm') {
|
|
938
|
+
return {
|
|
939
|
+
...base,
|
|
940
|
+
state: 'broken',
|
|
941
|
+
verified: true,
|
|
942
|
+
detail: `${where} · "${cmd}" is run through npm's own JavaScript entry point (a .cmd shim cannot be spawned without a shell) and no npm installation was found next to this node`,
|
|
943
|
+
fix: `install npm alongside this node, or set "${server?.name}".command in ${fixFile} to a full path to the server's executable`,
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* ⚠️⚠️ "COULD NOT LOOK" IS NOT "FOUND IT", AND THE FIRST DRAFT SAID IT WAS.
|
|
949
|
+
* With no PATH to search, `resolveMcpCommand` answers `unknown` — and this
|
|
950
|
+
* function fell through to the healthy branch and printed
|
|
951
|
+
* `"linear-mcp" resolves`, a sentence with no evidence behind it whatsoever.
|
|
952
|
+
* Caught by a test, which is the only reason it is not in the product.
|
|
953
|
+
*/
|
|
954
|
+
if (resolution.kind === 'unknown') {
|
|
955
|
+
return {
|
|
956
|
+
...base,
|
|
957
|
+
state: 'live',
|
|
958
|
+
verified: false,
|
|
959
|
+
detail: `${where} · "${cmd}" · not checked — there is no PATH in this environment to look it up in, and nothing here starts a server to find out`,
|
|
960
|
+
fix: null,
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const gaps = mcpCredentialGaps(server?.env, env);
|
|
965
|
+
if (gaps.length) {
|
|
966
|
+
const g = gaps[0];
|
|
967
|
+
const detail = g.kind === 'placeholder'
|
|
968
|
+
// ⚠️ The placeholder TEXT is described, never echoed — an echoed `${…}` is
|
|
969
|
+
// harmless, but the same line renders values that may not be.
|
|
970
|
+
? `${where} · its "${g.key}" entry is a $\{…} placeholder, and this client does not expand those — the server receives that text literally, and it OVERRIDES the real ${g.ref} in your environment`
|
|
971
|
+
: `${where} · its "${g.key}" entry is empty, which OVERRIDES any real ${g.key} in your environment with nothing`;
|
|
972
|
+
return {
|
|
973
|
+
...base,
|
|
974
|
+
state: 'broken',
|
|
975
|
+
verified: true,
|
|
976
|
+
detail,
|
|
977
|
+
fix: `delete the "${g.key}" line from "${server?.name}".env in ${fixFile} so the server inherits ${g.ref ?? g.key} from your shell (this client passes the whole environment through)`,
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const via = resolution.kind === 'node-entry' ? ` (run as node ${cmd}-cli.js, the only shell-free way on Windows)` : '';
|
|
982
|
+
return {
|
|
983
|
+
...base,
|
|
984
|
+
state: 'live',
|
|
985
|
+
verified: false,
|
|
986
|
+
detail: `${where} · "${cmd}" resolves${via} · not checked — the doctor never starts an MCP server (an npx server can take minutes on a cold cache, and a diagnostic must not hang)`,
|
|
987
|
+
fix: null,
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* ── ⚠️⚠️ A JSON PARSE ERROR QUOTES THE FILE BACK AT YOU ─────────────────────
|
|
993
|
+
*
|
|
994
|
+
* MEASURED ON NODE 22.17, and it is the exact trap `model.mjs` documents for
|
|
995
|
+
* proxy error pages, arriving through a completely different door:
|
|
996
|
+
*
|
|
997
|
+
* JSON.parse('…"env":{"T":ghp_REALTOKEN0123…')
|
|
998
|
+
* -> Unexpected token 'g', ..."env":{"T":ghp_REALTO"... is not valid JSON
|
|
999
|
+
*
|
|
1000
|
+
* V8 echoes roughly twenty characters of the source around the fault. An
|
|
1001
|
+
* `mcp.json` is one of the few files people type an API token straight into, so
|
|
1002
|
+
* a stray comma in it would print part of that token to the terminal, to CI
|
|
1003
|
+
* logs, and into whatever the user pastes into a bug report — and `SECRET_ENV_VARS`
|
|
1004
|
+
* cannot help, because the value was never an environment variable.
|
|
1005
|
+
*
|
|
1006
|
+
* ⭐ THE ECHO IS ALSO THE LEAST USEFUL PART OF THE MESSAGE. "Unexpected token
|
|
1007
|
+
* 'g' … is not valid JSON", the position, and the file name are what a person
|
|
1008
|
+
* acts on; the twenty quoted characters are already on their screen in the
|
|
1009
|
+
* editor. So this drops the echo and keeps everything else.
|
|
1010
|
+
*/
|
|
1011
|
+
export function redactConfigEcho(message) {
|
|
1012
|
+
return String(message ?? '')
|
|
1013
|
+
// `..."<echo>"...` — the form V8 uses mid-file.
|
|
1014
|
+
.replace(/\.\.\."[\s\S]*?"\.\.\./g, '…')
|
|
1015
|
+
// `"<echo>"...` — the form it uses at the very start of the input.
|
|
1016
|
+
.replace(/"[\s\S]*?"\.\.\./g, '…')
|
|
1017
|
+
.slice(0, 240);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/** The whole MCP section, from an already-read config. */
|
|
1021
|
+
/**
|
|
1022
|
+
* @param {object} [options]
|
|
1023
|
+
* @param {number} [options.offeredCount] how many tools the model is ACTUALLY
|
|
1024
|
+
* offered here. ⚠️ Defaults to the registry size only so a direct caller that
|
|
1025
|
+
* omits it still produces a sentence; `runDoctor` always passes the real
|
|
1026
|
+
* number, because the registry size is the flattering one and the two rows
|
|
1027
|
+
* sitting next to each other must agree.
|
|
1028
|
+
*/
|
|
1029
|
+
export function mcpChecks(cfg, env = {}, { resolve = resolveMcpCommand, offeredCount = TOOL_NAMES.length } = {}) {
|
|
1030
|
+
const offeredHere = offeredCount;
|
|
1031
|
+
const names = MCP_CONFIG_FILES.join(' or ');
|
|
1032
|
+
|
|
1033
|
+
if (!cfg?.ok) {
|
|
1034
|
+
return [{
|
|
1035
|
+
id: 'mcp.config',
|
|
1036
|
+
label: 'mcp config',
|
|
1037
|
+
state: 'broken',
|
|
1038
|
+
verified: true,
|
|
1039
|
+
detail: `${redactConfigEcho(cfg?.error) || 'the MCP config could not be read'} — no external server is connected while this stands`,
|
|
1040
|
+
fix: `fix or delete the file (${names}); a config that will not parse takes every server with it`,
|
|
1041
|
+
}];
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
if (!cfg.file || cfg.servers.length === 0) {
|
|
1045
|
+
return [{
|
|
1046
|
+
id: 'mcp.config',
|
|
1047
|
+
label: 'mcp servers',
|
|
1048
|
+
state: 'dark',
|
|
1049
|
+
verified: true,
|
|
1050
|
+
/**
|
|
1051
|
+
* ⚠️ THE OFFERED COUNT, NOT `TOOL_NAMES.length` — AND THEY DIFFER BY 13.
|
|
1052
|
+
*
|
|
1053
|
+
* This line used to quote the size of the registry while the row directly
|
|
1054
|
+
* above it printed "34 of 47". Two adjacent rows of one report giving
|
|
1055
|
+
* different answers to "how many tools does the model have" is worse than
|
|
1056
|
+
* either being wrong alone: `--doctor` exists to tell you what is actually
|
|
1057
|
+
* working here, so a diagnostic that disagrees with itself cannot be used
|
|
1058
|
+
* at all without checking it.
|
|
1059
|
+
*
|
|
1060
|
+
* ⚠️ And 47 was the FLATTERING number, which is the direction that costs
|
|
1061
|
+
* trust. On a bare machine 8 tools need infrastructure we host, 4 need
|
|
1062
|
+
* TypeScript in the user's own project, and 1 needs a skills directory.
|
|
1063
|
+
*/
|
|
1064
|
+
detail: cfg.file
|
|
1065
|
+
? `${cfg.file} declares no servers, so the model sees only the ${offeredHere} tools available in this workspace`
|
|
1066
|
+
: `no ${names} in this workspace, so the model sees only the ${offeredHere} tools available in this workspace`,
|
|
1067
|
+
fix: `create ${MCP_CONFIG_FILES[0]} with an "mcpServers" object to give it tools we did not build (the user chooses the servers — there is deliberately no tool that lets the model add its own)`,
|
|
1068
|
+
}];
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const checks = cfg.servers.map((s) => assessMcpServer(s, { file: cfg.file, env, resolution: resolve(s.command, { env }) }));
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* ⚠️ readMcpConfig STOPS AT MAX_SERVERS AND SAYS NOTHING. A ninth server in
|
|
1075
|
+
* the file is simply never connected, which from the outside looks exactly
|
|
1076
|
+
* like a server that failed to start.
|
|
1077
|
+
*/
|
|
1078
|
+
if (cfg.servers.length >= MAX_SERVERS) {
|
|
1079
|
+
checks.push({
|
|
1080
|
+
id: 'mcp.cap',
|
|
1081
|
+
label: 'server cap',
|
|
1082
|
+
state: 'dark',
|
|
1083
|
+
verified: true,
|
|
1084
|
+
detail: `${cfg.servers.length} servers is the MAX_SERVERS ceiling — any declared after the first ${MAX_SERVERS} in ${cfg.file} are silently not connected`,
|
|
1085
|
+
fix: `remove servers from ${cfg.file} until there are fewer than ${MAX_SERVERS}, so nothing is dropped without being reported`,
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
return checks;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* ── ⭐⭐ THE CURATED CATALOGUE, WHICH HAD NO DOOR AT ALL ─────────────────────
|
|
1093
|
+
*
|
|
1094
|
+
* `lib/mcp-defaults.mjs` is 500 lines of hand-verified integration work with
|
|
1095
|
+
* **zero importers** — measured by an import-graph probe over `lib/`, `bin/` and
|
|
1096
|
+
* `scripts/`: one test imported it and nothing else did. So a user could not
|
|
1097
|
+
* reach any of it. They had to hand-write `.acuvo/mcp.json` from install lines
|
|
1098
|
+
* the repo itself recorded as wrong, which is the exact "built but unreached"
|
|
1099
|
+
* shape this project keeps re-learning: a screenshot beats any number of green
|
|
1100
|
+
* tests, and a module nobody can call is a module that does not exist.
|
|
1101
|
+
*
|
|
1102
|
+
* ⭐ THE DOCTOR IS THE RIGHT DOOR because it is already the place a stranger
|
|
1103
|
+
* goes to ask "what can this thing do here, and what would it take to do more".
|
|
1104
|
+
* The catalogue answers exactly that question and its `assessEntry` already
|
|
1105
|
+
* returns doctor's own `state`/`detail`/`fix` shape — it was written for this
|
|
1106
|
+
* seam and then never plugged into it.
|
|
1107
|
+
*
|
|
1108
|
+
* ⚠️ ONLY ENTRIES WE ACTUALLY RAN APPEAR HERE. The catalogue has a second,
|
|
1109
|
+
* INERT tier of servers nobody on this project has started; a health report is
|
|
1110
|
+
* the wrong place for "someone else says this works". Those stay in
|
|
1111
|
+
* `formatAvailability` and the starter config, where they are labelled
|
|
1112
|
+
* unverified. The doctor shows what was measured.
|
|
1113
|
+
*
|
|
1114
|
+
* ⚠️ AND IT NEVER SPAWNS ANYTHING. `assessCatalogue` is pure — that is its whole
|
|
1115
|
+
* reason for existing (each dark entry it rules out is a measured 20,052ms
|
|
1116
|
+
* handshake timeout not spent) — and the ONLY I/O added here is `existsSync` on
|
|
1117
|
+
* candidate `node_modules` directories.
|
|
1118
|
+
*
|
|
1119
|
+
* ⚠️ IDS ARE NAMESPACED `mcp.catalogue.<name>`, NOT `mcp.<name>`. `assessEntry`
|
|
1120
|
+
* returns the latter and so does `assessMcpServer` for a user's own server — a
|
|
1121
|
+
* user with a server called `browser` would have produced two rows sharing an
|
|
1122
|
+
* id, and `find(report, id)` returns the first. Two different facts under one
|
|
1123
|
+
* key is how a report starts lying without anyone editing a sentence.
|
|
1124
|
+
*/
|
|
1125
|
+
export function mcpCatalogueChecks({ env = {}, root = '.', declaredNames = [], installedImpl = installedPackages } = {}) {
|
|
1126
|
+
/**
|
|
1127
|
+
* ⚠️ A SERVER THE USER ALREADY DECLARED IS REPORTED BY `mcpChecks` ABOVE,
|
|
1128
|
+
* from their actual config — including whether its command resolves. Repeating
|
|
1129
|
+
* it from the catalogue would put two rows about one server in one section,
|
|
1130
|
+
* and the catalogue's row is the weaker of the two (it knows the entry, not
|
|
1131
|
+
* the user's args).
|
|
1132
|
+
*/
|
|
1133
|
+
const declared = new Set(declaredNames);
|
|
1134
|
+
const installed = installedImpl({ env, root, packages: CATALOGUE.map(packageOf).filter(Boolean) });
|
|
1135
|
+
const rows = assessCatalogue({ env, installed });
|
|
1136
|
+
const verified = rows.filter((r) => r.verified);
|
|
1137
|
+
const live = verified.filter((r) => r.state === 'live');
|
|
1138
|
+
|
|
1139
|
+
const checks = [{
|
|
1140
|
+
id: 'mcp.catalogue',
|
|
1141
|
+
label: 'curated servers',
|
|
1142
|
+
state: live.length > 0 ? 'live' : 'dark',
|
|
1143
|
+
verified: true,
|
|
1144
|
+
detail: `${live.length} of ${verified.length} servers we have run ourselves are usable here${live.length ? ` (${live.map((r) => r.label).join(', ')})` : ''} — ${CATALOGUE.length} in the catalogue in total`,
|
|
1145
|
+
fix: live.length > 0
|
|
1146
|
+
? `add one to ${STARTER_CONFIG_FILE} under "mcpServers" to turn it on`
|
|
1147
|
+
: `install one of the servers below, then declare it in ${STARTER_CONFIG_FILE}`,
|
|
1148
|
+
}];
|
|
1149
|
+
|
|
1150
|
+
for (const r of verified) {
|
|
1151
|
+
if (declared.has(r.entry)) continue;
|
|
1152
|
+
checks.push({
|
|
1153
|
+
id: `mcp.catalogue.${r.entry}`,
|
|
1154
|
+
label: r.label,
|
|
1155
|
+
state: r.state,
|
|
1156
|
+
// ⚠️ `verified` here means "we ran this server", which is the same claim
|
|
1157
|
+
// the doctor's own column makes: this line is a measurement, not a table
|
|
1158
|
+
// lookup. Every row in this loop passed that filter.
|
|
1159
|
+
verified: true,
|
|
1160
|
+
detail: `${r.purpose} — ${r.detail}`,
|
|
1161
|
+
fix: r.fix,
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
return checks;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Which of these npm packages are on this machine.
|
|
1169
|
+
*
|
|
1170
|
+
* ⚠️ NO SPAWN. `npm ls -g` would be the obvious implementation and it costs
|
|
1171
|
+
* hundreds of milliseconds on the doctor's hot path — the same mistake that
|
|
1172
|
+
* made the credential finder a 6.8s directory crawl. This is `existsSync` on a
|
|
1173
|
+
* short list of known roots.
|
|
1174
|
+
*
|
|
1175
|
+
* ⚠️ THE WINDOWS GLOBAL ROOT IS NOT BESIDE `node.exe`. npm's default prefix on
|
|
1176
|
+
* Windows is `%APPDATA%\npm`, so globals land in `%APPDATA%\npm\node_modules`
|
|
1177
|
+
* while `dirname(process.execPath)\node_modules` holds only npm itself.
|
|
1178
|
+
* Checking only the latter reported every globally installed server as missing.
|
|
1179
|
+
*
|
|
1180
|
+
* ⚠️ AND A MISS IS NOT A LIE. `assessEntry` words a negative as "not installed",
|
|
1181
|
+
* which is the honest reading of "not found in any root we know" — the cost of
|
|
1182
|
+
* being wrong is one unnecessary install line, never a false all-clear.
|
|
1183
|
+
*/
|
|
1184
|
+
export function installedPackages({ env = {}, root = '.', packages = [], existsImpl = existsSync } = {}) {
|
|
1185
|
+
const nodeDir = dirname(process.execPath);
|
|
1186
|
+
const roots = [
|
|
1187
|
+
join(root, 'node_modules'),
|
|
1188
|
+
join(nodeDir, 'node_modules'),
|
|
1189
|
+
join(nodeDir, '..', 'lib', 'node_modules'),
|
|
1190
|
+
...(env.npm_config_prefix ? [join(env.npm_config_prefix, 'node_modules'), join(env.npm_config_prefix, 'lib', 'node_modules')] : []),
|
|
1191
|
+
...(env.APPDATA ? [join(env.APPDATA, 'npm', 'node_modules')] : []),
|
|
1192
|
+
];
|
|
1193
|
+
const found = new Set();
|
|
1194
|
+
for (const pkg of packages) {
|
|
1195
|
+
for (const r of roots) {
|
|
1196
|
+
// package.json, not the directory: an empty leftover folder is not an
|
|
1197
|
+
// installed package, and npm leaves those behind on a failed install.
|
|
1198
|
+
if (existsImpl(join(r, pkg, 'package.json'))) { found.add(pkg); break; }
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
return found;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1205
|
+
// PURE: PROMPT CACHING — DOES THE CONFIGURED MODEL CACHE AT ALL
|
|
1206
|
+
//
|
|
1207
|
+
// ── ⭐ WHY THIS DESERVES A SECTION ──────────────────────────────────────────
|
|
1208
|
+
// The loop's whole economic design assumes an automatically-cached prefix:
|
|
1209
|
+
// `turn.mjs` appends rather than rebuilds, `learned.mjs` renders sorted and
|
|
1210
|
+
// timestamp-free, and the loop nudges once per session — all so the prefix
|
|
1211
|
+
// stays byte-identical. Measured on DeepSeek: 97.2% cached and 3-4x cheaper.
|
|
1212
|
+
//
|
|
1213
|
+
// ⚠️ ON A MODEL THAT DOES NOT CACHE, EVERY ONE OF THOSE CONTORTIONS BUYS
|
|
1214
|
+
// NOTHING, and the run costs several times what the user expects with nothing
|
|
1215
|
+
// reporting it. That is a money surprise, which is the class of failure this
|
|
1216
|
+
// package treats most seriously.
|
|
1217
|
+
//
|
|
1218
|
+
// ⚠️ UNKNOWN IS THE DEFAULT AND IT IS DELIBERATE. A caching claim invented for
|
|
1219
|
+
// a model nobody measured turns a cost estimate into fiction. Add to the table
|
|
1220
|
+
// only with a source, and prefer "not known" every other time.
|
|
1221
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1222
|
+
|
|
1223
|
+
/** Caches the prefix with no flag in the request. */
|
|
1224
|
+
const AUTO_CACHE = [
|
|
1225
|
+
// Measured in this repo, 2026-08-11 (see turn.mjs) — automatic, on disk.
|
|
1226
|
+
/^deepseek\//i,
|
|
1227
|
+
// Documented automatic prompt caching above ~1k tokens.
|
|
1228
|
+
/^openai\/(gpt-4o|gpt-4\.1|gpt-5|o1|o3|o4)/i,
|
|
1229
|
+
// Implicit caching, 2.5 and newer (1.5 was explicit-only).
|
|
1230
|
+
/^google\/gemini-(2\.5|3)/i,
|
|
1231
|
+
];
|
|
1232
|
+
/**
|
|
1233
|
+
* Caches ONLY when the request carries explicit breakpoints — which this client
|
|
1234
|
+
* does not send: `callModel` builds one plain OpenAI-shaped body.
|
|
1235
|
+
*/
|
|
1236
|
+
const EXPLICIT_CACHE = [/^anthropic\//i];
|
|
1237
|
+
|
|
1238
|
+
export function cachingSupport(modelId) {
|
|
1239
|
+
const id = String(modelId ?? '').trim();
|
|
1240
|
+
if (!id) return { kind: 'unknown' };
|
|
1241
|
+
if (AUTO_CACHE.some((re) => re.test(id))) return { kind: 'automatic' };
|
|
1242
|
+
if (EXPLICIT_CACHE.some((re) => re.test(id))) return { kind: 'explicit' };
|
|
1243
|
+
return { kind: 'unknown' };
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
export function cacheChecks({ model, maxRounds = 8 } = {}) {
|
|
1247
|
+
const support = cachingSupport(model);
|
|
1248
|
+
const checks = [];
|
|
1249
|
+
|
|
1250
|
+
if (support.kind === 'automatic') {
|
|
1251
|
+
checks.push({
|
|
1252
|
+
id: 'cache.model',
|
|
1253
|
+
label: 'prompt cache',
|
|
1254
|
+
state: 'live',
|
|
1255
|
+
/**
|
|
1256
|
+
* ⚠️ verified:false, AND THE DETAIL SAYS WHY IN WORDS. This is a fact
|
|
1257
|
+
* about the MODEL, read from a table — nothing here measured your run, and
|
|
1258
|
+
* a green tick claiming otherwise is exactly the false all-clear this file
|
|
1259
|
+
* exists to prevent. That half is unchanged and correct.
|
|
1260
|
+
*
|
|
1261
|
+
* ⚠️⚠️ THE OTHER HALF WAS A STALE FACT REPORTED BY THE DIAGNOSTIC TOOL —
|
|
1262
|
+
* the exact defect class this file was built to catch. The detail used to
|
|
1263
|
+
* end "the real hit rate is not instrumented yet", and that has never been
|
|
1264
|
+
* true: it was written in the same commit that instrumented it.
|
|
1265
|
+
* `readCacheUsage` reads cached tokens per round, `aggregateCache` totals
|
|
1266
|
+
* them per session, `cacheClause` prints them on the summary line, and
|
|
1267
|
+
* `toJson` emits them into `--json` and therefore into `.acuvo/audit/`.
|
|
1268
|
+
* Telling the user their hit rate does not exist, while the run they just
|
|
1269
|
+
* did printed it, is worse than saying nothing.
|
|
1270
|
+
*
|
|
1271
|
+
* ⚠️ AND IT NOW NAMES THE REAL CAUSE OF A LOW NUMBER. Measured 2026-08-14
|
|
1272
|
+
* on one identical 4-round task: 46.7% unpinned against 95.8% pinned. The
|
|
1273
|
+
* variance is UPSTREAM ROUTING across the model's 28 endpoints, not prefix
|
|
1274
|
+
* discipline — so a user who reads "your cache is bad" and goes hunting
|
|
1275
|
+
* their prompt is hunting the wrong thing.
|
|
1276
|
+
*/
|
|
1277
|
+
verified: false,
|
|
1278
|
+
detail: `${model} caches the prompt prefix automatically — a continuing session re-sends an identical prefix and is billed a fraction for it. This line is the model's documented behaviour, not a measurement of your runs; YOUR hit rate is measured on every run and printed on the summary line, and in --json as cache.hitRate. ⚠️ It varies mostly with UPSTREAM ROUTING rather than with your prefix — measured 2026-08-14 on one identical 4-round task, 46.7% unpinned against 95.8% pinned via ACUVO_PROVIDER_ORDER. --json also reports \`providers\`, which names who served each round.`,
|
|
1279
|
+
fix: null,
|
|
1280
|
+
});
|
|
1281
|
+
} else if (support.kind === 'explicit') {
|
|
1282
|
+
checks.push({
|
|
1283
|
+
id: 'cache.model',
|
|
1284
|
+
label: 'prompt cache',
|
|
1285
|
+
state: 'dark',
|
|
1286
|
+
verified: true,
|
|
1287
|
+
detail: `${model} caches only when the request carries cache_control breakpoints, and this client sends none — every round pays the full prompt price, however stable the prefix is`,
|
|
1288
|
+
fix: `set OPENROUTER_CODEGEN_MODEL to a model that caches automatically (e.g. ${DEFAULT_MODEL}) if the cost of a long session matters`,
|
|
1289
|
+
});
|
|
1290
|
+
} else {
|
|
1291
|
+
checks.push({
|
|
1292
|
+
id: 'cache.model',
|
|
1293
|
+
label: 'prompt cache',
|
|
1294
|
+
state: 'dark',
|
|
1295
|
+
verified: false,
|
|
1296
|
+
detail: `${model || 'this model'} is not known here to cache the prompt prefix automatically — cost this run as if every round pays full price for the whole prompt`,
|
|
1297
|
+
fix: `check the provider's docs, or set OPENROUTER_CODEGEN_MODEL to a model that is known to cache automatically (e.g. ${DEFAULT_MODEL})`,
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* ⭐ A CACHE HIT NEEDS A SECOND ROUND. In a single-shot run the prefix is
|
|
1303
|
+
* sent exactly once, so no model on earth can save anything — worth saying,
|
|
1304
|
+
* because otherwise the line above reads like money being saved that is not.
|
|
1305
|
+
*/
|
|
1306
|
+
if (maxRounds <= 1) {
|
|
1307
|
+
checks.push({
|
|
1308
|
+
id: 'cache.rounds',
|
|
1309
|
+
label: 'rounds',
|
|
1310
|
+
state: 'dark',
|
|
1311
|
+
verified: true,
|
|
1312
|
+
detail: 'this is a single-shot run: the prompt prefix is sent once, so no cache hit is possible whatever the model supports',
|
|
1313
|
+
fix: 'raise the round budget (--max-rounds N) — caching only pays from the second round onward',
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
return checks;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1320
|
+
// THE EDGE: PROBES
|
|
1321
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1322
|
+
|
|
1323
|
+
const TIMED_OUT = Symbol('timed-out');
|
|
1324
|
+
|
|
1325
|
+
/** Never throws. A failure is data, with the code that caused it. */
|
|
1326
|
+
async function httpProbe(fetchImpl, url, init, timeoutMs) {
|
|
1327
|
+
let res;
|
|
1328
|
+
try {
|
|
1329
|
+
res = await withTimeout(
|
|
1330
|
+
// ⚠️ `.then()` so a fetchImpl that throws SYNCHRONOUSLY becomes a
|
|
1331
|
+
// rejection rather than an exception escaping this function.
|
|
1332
|
+
Promise.resolve().then(() => fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) })),
|
|
1333
|
+
timeoutMs,
|
|
1334
|
+
TIMED_OUT,
|
|
1335
|
+
);
|
|
1336
|
+
} catch (err) {
|
|
1337
|
+
const code = err?.cause?.code || err?.name || 'unknown';
|
|
1338
|
+
return { kind: 'unreachable', detail: String(code) };
|
|
1339
|
+
}
|
|
1340
|
+
if (res === TIMED_OUT) return { kind: 'unreachable', detail: `no answer within ${timeoutMs}ms` };
|
|
1341
|
+
|
|
1342
|
+
let text = '';
|
|
1343
|
+
try { text = await res.text(); } catch { text = ''; }
|
|
1344
|
+
let json = null;
|
|
1345
|
+
try { json = JSON.parse(text); } catch { /* not JSON — the text is all we have */ }
|
|
1346
|
+
return { status: res.status, json, text };
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/** Is this string the service saying "your credential is wrong"? */
|
|
1350
|
+
function readsAsAuthFailure(s) {
|
|
1351
|
+
return /unauthoris|unauthoriz|forbidden|invalid secret|invalid token|not authenticated|401|403/i.test(String(s ?? ''));
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* ── ⭐ THE MEDIA HEALTH PROBE: THE SECRET, AND NO PAYLOAD ───────────────────
|
|
1356
|
+
*
|
|
1357
|
+
* A payload complaint is PROOF the credential was accepted, and it costs the
|
|
1358
|
+
* service nothing to produce — no GPU, no render, no synthesis. Measured
|
|
1359
|
+
* round trips on the live endpoints: 284ms–1195ms.
|
|
1360
|
+
*/
|
|
1361
|
+
async function probeMediaEndpoint(fetchImpl, url, secret, timeoutMs) {
|
|
1362
|
+
const raw = await httpProbe(fetchImpl, url, {
|
|
1363
|
+
method: 'POST',
|
|
1364
|
+
headers: { 'content-type': 'application/json' },
|
|
1365
|
+
body: JSON.stringify(secret ? { secret } : {}),
|
|
1366
|
+
}, timeoutMs);
|
|
1367
|
+
if (raw.kind) return raw;
|
|
1368
|
+
|
|
1369
|
+
if (raw.status === 401 || raw.status === 403) return { kind: 'refused', detail: `HTTP ${raw.status}` };
|
|
1370
|
+
if (raw.status >= 400) return { kind: 'http', detail: `HTTP ${raw.status}` };
|
|
1371
|
+
if (!raw.json) return { kind: 'http', detail: `HTTP ${raw.status} but the body was not JSON — this may not be the service you think it is` };
|
|
1372
|
+
if (raw.json.ok === false && readsAsAuthFailure(raw.json.error)) {
|
|
1373
|
+
return { kind: 'refused', detail: String(raw.json.error).slice(0, 120) };
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* ⭐ `ok:false` WITH A PAYLOAD COMPLAINT IS THE HEALTHY ANSWER. We sent no
|
|
1377
|
+
* payload on purpose; the service telling us so is the credential being
|
|
1378
|
+
* accepted. Treating it as a failure would report every working endpoint red.
|
|
1379
|
+
*/
|
|
1380
|
+
return { kind: 'ok', detail: raw.json.ok === false ? `answered: ${String(raw.json.error).slice(0, 80)}` : 'answered ok' };
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1384
|
+
// THE EDGE: runDoctor
|
|
1385
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1386
|
+
|
|
1387
|
+
/** The engines floor this package declares, read from its own manifest. */
|
|
1388
|
+
function readOwnEngines() {
|
|
1389
|
+
try {
|
|
1390
|
+
const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'));
|
|
1391
|
+
return pkg?.engines?.node ?? null;
|
|
1392
|
+
} catch {
|
|
1393
|
+
return null;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* @param {object} opts
|
|
1399
|
+
* @param {Record<string,string|undefined>} [opts.env]
|
|
1400
|
+
* @param {Function|null} [opts.fetchImpl] null = do not touch the network at all
|
|
1401
|
+
* @param {string} [opts.root]
|
|
1402
|
+
*/
|
|
1403
|
+
export async function runDoctor({
|
|
1404
|
+
env = process.env,
|
|
1405
|
+
fetchImpl = fetch,
|
|
1406
|
+
root = process.cwd(),
|
|
1407
|
+
now = Date.now,
|
|
1408
|
+
timeoutMs = DEFAULT_PROBE_TIMEOUT_MS,
|
|
1409
|
+
allowRun = true,
|
|
1410
|
+
maxRounds = 8,
|
|
1411
|
+
nodeVersion = process.version,
|
|
1412
|
+
platform = `${process.platform} ${process.arch}`,
|
|
1413
|
+
engines = undefined,
|
|
1414
|
+
gitStatusImpl = realGitStatus,
|
|
1415
|
+
/**
|
|
1416
|
+
* ⚠️ WE DO NOT INHERIT git.mjs's BOUND, WE IMPOSE OUR OWN. `GIT_TIMEOUT_MS`
|
|
1417
|
+
* there is 20s, which is a sensible bound for a tool call and a terrible one
|
|
1418
|
+
* for a diagnostic — and an INJECTED `gitStatusImpl` is under no obligation
|
|
1419
|
+
* to have any bound at all. Same argument as `withTimeout` for fetch.
|
|
1420
|
+
*/
|
|
1421
|
+
gitTimeoutMs = 15_000,
|
|
1422
|
+
readFileImpl = readFileSync,
|
|
1423
|
+
/**
|
|
1424
|
+
* ⚠️ IT READS THE CONFIG, IT NEVER CONNECTS. `readMcpConfig` only parses a
|
|
1425
|
+
* file; `connectServer` — the function that spawns a program — is deliberately
|
|
1426
|
+
* not imported into this module at all, so no future edit here can start one
|
|
1427
|
+
* by accident.
|
|
1428
|
+
*/
|
|
1429
|
+
mcpConfigImpl = readMcpConfig,
|
|
1430
|
+
/**
|
|
1431
|
+
* ── ⚠️⚠️ THE PROMISE WAS FALSE, AND IT WAS THE WORST ONE TO GET WRONG ──────
|
|
1432
|
+
*
|
|
1433
|
+
* `--help` and `README.md` both said `--doctor` "Needs no API key and no
|
|
1434
|
+
* network". It sends `Authorization: Bearer <the user's key>` to
|
|
1435
|
+
* openrouter.ai/api/v1/key and /credits, and probes every Modal endpoint.
|
|
1436
|
+
*
|
|
1437
|
+
* ⭐ WHY THIS ONE MATTERS MORE THAN AN ORDINARY DOC BUG: `--doctor` is what a
|
|
1438
|
+
* security-conscious evaluator runs FIRST, precisely BECAUSE they were told it
|
|
1439
|
+
* was offline. They then find their key in a corporate proxy log. The lie and
|
|
1440
|
+
* the audience are perfectly aligned to destroy trust on first contact.
|
|
1441
|
+
*
|
|
1442
|
+
* ⚠️ THE ANSWER IS NOT TO DELETE THE PROBES. Verifying that a key actually
|
|
1443
|
+
* authenticates is the most valuable thing this command does — "present, but
|
|
1444
|
+
* it does NOT authenticate" is a real diagnosis no offline check can make. So:
|
|
1445
|
+
* the docs now say what it does, and `--offline` delivers what they used to
|
|
1446
|
+
* promise, for an air-gapped machine or anyone who wants to read the code
|
|
1447
|
+
* before it touches the network.
|
|
1448
|
+
*
|
|
1449
|
+
* ⚠️ NAMED `skipNetwork`, NOT `offline`, DELIBERATELY. This function already
|
|
1450
|
+
* computes an `offline` VERDICT further down — "every probe was unreachable,
|
|
1451
|
+
* so this laptop has no connection" — and those are opposite things: one is a
|
|
1452
|
+
* request not to look, the other is a finding from having looked. Reusing the
|
|
1453
|
+
* word was a redeclaration error, and it would have been a reasoning error
|
|
1454
|
+
* even if the language had allowed it.
|
|
1455
|
+
*/
|
|
1456
|
+
skipNetwork = false,
|
|
1457
|
+
} = {}) {
|
|
1458
|
+
const probed = typeof fetchImpl === 'function' && skipNetwork !== true;
|
|
1459
|
+
const model = readModelConfig(env);
|
|
1460
|
+
const media = mediaConfig(env);
|
|
1461
|
+
const edit = editConfig(env);
|
|
1462
|
+
const image = imageConfig(env);
|
|
1463
|
+
|
|
1464
|
+
// ── plan every network probe, then fire them all at once ─────────────────
|
|
1465
|
+
/**
|
|
1466
|
+
* ⚠️ `see_page` READS TWO VARIABLES, AND THE REPORT MUST NAME THE ONE IN
|
|
1467
|
+
* PLAY. Caught by looking at a live run: with both unset the line read
|
|
1468
|
+
* "MODAL_RENDER_AUDIT_URL is unset", which points a new user at the FALLBACK
|
|
1469
|
+
* name — a fix that names the wrong variable is worse than no fix, because
|
|
1470
|
+
* it gets followed. Primary when nothing is set; the fallback only when the
|
|
1471
|
+
* fallback is the one actually carrying the value.
|
|
1472
|
+
*/
|
|
1473
|
+
const renderVar = env.RENDER_AUDIT_URL?.trim() ? 'RENDER_AUDIT_URL'
|
|
1474
|
+
: env.MODAL_RENDER_AUDIT_URL?.trim() ? 'MODAL_RENDER_AUDIT_URL'
|
|
1475
|
+
: 'RENDER_AUDIT_URL';
|
|
1476
|
+
/**
|
|
1477
|
+
* ── ⚠️⚠️ `builtIn` — BECAUSE THE ADVICE WAS TELLING PEOPLE TO DO SIX TIMES
|
|
1478
|
+
* THE WORK, AND THE WRONG WORK ─────────────────────────────────────
|
|
1479
|
+
*
|
|
1480
|
+
* Measured 2026-08-12 on a bare machine: the doctor printed six lines saying
|
|
1481
|
+
* "MODAL_TTS_URL is unset → set MODAL_TTS_URL to your endpoint URL", and so on
|
|
1482
|
+
* for all five. **Every one of those URLs is already baked into media.mjs as a
|
|
1483
|
+
* default.** Nobody needs to set them. `mediaConfig` returns them the moment a
|
|
1484
|
+
* SECRET exists (`url(k) || (secret ? fallback : null)`) — so the single real
|
|
1485
|
+
* blocker is one credential, and the doctor was sending its reader off to find
|
|
1486
|
+
* five endpoint URLs they do not have and cannot guess.
|
|
1487
|
+
*
|
|
1488
|
+
* ⭐ Wrong advice in a diagnostic is worse than no advice: it is followed. A
|
|
1489
|
+
* doctor exists to turn "it does not work" into one action, and this one
|
|
1490
|
+
* turned it into five impossible ones.
|
|
1491
|
+
*
|
|
1492
|
+
* ⚠️ `see_page` IS GENUINELY DIFFERENT and must keep the old message. The
|
|
1493
|
+
* renderer has NO baked-in default (see `mediaConfig`: `render` is
|
|
1494
|
+
* `url(...)` with no fallback), so there `RENDER_AUDIT_URL` really is the
|
|
1495
|
+
* thing to set. One flag distinguishes them so the two can never drift.
|
|
1496
|
+
*/
|
|
1497
|
+
const mediaTargets = [
|
|
1498
|
+
{ id: 'see_page', label: 'see_page', envVar: renderVar, url: media.render, builtIn: false },
|
|
1499
|
+
{ id: 'speak', label: 'speak', envVar: 'MODAL_TTS_URL', url: media.speak, builtIn: true },
|
|
1500
|
+
{ id: 'transcribe', label: 'transcribe', envVar: 'MODAL_TRANSCRIBE_URL', url: media.transcribe, builtIn: true },
|
|
1501
|
+
{ id: 'make_document', label: 'make_document', envVar: 'MODAL_PRESS_URL', url: media.document, builtIn: true },
|
|
1502
|
+
{ id: 'read_document', label: 'read_document', envVar: 'MODAL_DOC_READ_URL', url: media.docRead, builtIn: true },
|
|
1503
|
+
{ id: 'read_table', label: 'read_table', envVar: 'MODAL_TABLE_READ_URL', url: media.tableRead, builtIn: true },
|
|
1504
|
+
// ⭐ PROBED SEPARATELY, NOT AS ONE "image editing" LINE. They are two Modal
|
|
1505
|
+
// apps, and the failure a person needs to see is "select is dark, so
|
|
1506
|
+
// edit_image cannot work while expand_image still can" — a merged line
|
|
1507
|
+
// cannot say that.
|
|
1508
|
+
{ id: 'select', label: 'edit_image (select)', envVar: 'MODAL_SELECT_URL', url: edit.select },
|
|
1509
|
+
{ id: 'flux', label: 'edit/expand (studio)', envVar: 'MODAL_FLUX_URL', url: edit.flux },
|
|
1510
|
+
];
|
|
1511
|
+
|
|
1512
|
+
const jobs = [];
|
|
1513
|
+
const push = (key, run) => { jobs.push(run().then((v) => [key, v], () => [key, { kind: 'unreachable', detail: 'probe failed' }])); };
|
|
1514
|
+
|
|
1515
|
+
if (probed) {
|
|
1516
|
+
if (model.configured) {
|
|
1517
|
+
push('or.key', () => httpProbe(fetchImpl, OR_KEY_URL, { headers: { authorization: `Bearer ${model.apiKey}` } }, timeoutMs));
|
|
1518
|
+
push('or.credits', () => httpProbe(fetchImpl, OR_CREDITS_URL, { headers: { authorization: `Bearer ${model.apiKey}` } }, timeoutMs));
|
|
1519
|
+
}
|
|
1520
|
+
// ⭐ ONE request answers the whole chain. Calling each model would cost money
|
|
1521
|
+
// and prove less: a 404 on an id is a catalogue fact, not a generation fact.
|
|
1522
|
+
push('or.models', () => httpProbe(fetchImpl, OR_MODELS_URL, {}, timeoutMs));
|
|
1523
|
+
for (const t of mediaTargets) {
|
|
1524
|
+
if (t.url && hostOf(t.url)) push(`media.${t.id}`, () => probeMediaEndpoint(fetchImpl, t.url, media.secret, timeoutMs));
|
|
1525
|
+
}
|
|
1526
|
+
if (image.configured && hostOf(image.base)) {
|
|
1527
|
+
// Measured: `GET /health` -> 200 {"ok":true,"browser":"idle"} in ~1s.
|
|
1528
|
+
push('image', () => httpProbe(fetchImpl, `${generateEndpoint(image.base).replace(/\/generate$/, '')}/health`, {}, timeoutMs));
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
const results = Object.fromEntries(await Promise.all(jobs));
|
|
1533
|
+
|
|
1534
|
+
/**
|
|
1535
|
+
* ⚠️ THE OFFLINE VERDICT IS COMPUTED OVER RAW TRANSPORT OUTCOMES, before any
|
|
1536
|
+
* of them has been turned into a state. Deciding "offline" from already-red
|
|
1537
|
+
* checks would be circular.
|
|
1538
|
+
*/
|
|
1539
|
+
const transportKinds = Object.entries(results).map(([, v]) => (v?.kind ? v : (v?.status !== undefined ? { kind: 'http' } : { kind: 'unreachable' })));
|
|
1540
|
+
const offline = probed ? isOffline(transportKinds) : false;
|
|
1541
|
+
|
|
1542
|
+
const sections = [];
|
|
1543
|
+
|
|
1544
|
+
// ── RUNTIME ──────────────────────────────────────────────────────────────
|
|
1545
|
+
sections.push({
|
|
1546
|
+
id: 'runtime',
|
|
1547
|
+
title: 'Runtime',
|
|
1548
|
+
checks: [
|
|
1549
|
+
checkNodeVersion(nodeVersion, engines === undefined ? readOwnEngines() : engines),
|
|
1550
|
+
{ id: 'runtime.platform', label: 'platform', state: 'live', verified: true, detail: platform, fix: null },
|
|
1551
|
+
],
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
// ── MODEL ────────────────────────────────────────────────────────────────
|
|
1555
|
+
const modelChecks = [];
|
|
1556
|
+
if (!model.configured) {
|
|
1557
|
+
modelChecks.push({
|
|
1558
|
+
id: 'model.key',
|
|
1559
|
+
label: 'OPENROUTER_API_KEY',
|
|
1560
|
+
state: 'dark',
|
|
1561
|
+
verified: true,
|
|
1562
|
+
detail: 'absent — every model call will refuse before it is sent',
|
|
1563
|
+
fix: 'set OPENROUTER_API_KEY (free to create at https://openrouter.ai/keys)',
|
|
1564
|
+
});
|
|
1565
|
+
} else {
|
|
1566
|
+
const p = results['or.key'];
|
|
1567
|
+
modelChecks.push(assessKey(p, offline, probed));
|
|
1568
|
+
const credits = assessCredits(results['or.credits'], offline, probed, modelChecks[0].state);
|
|
1569
|
+
if (credits) modelChecks.push(credits);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
const chain = safeChain(model.model, env);
|
|
1573
|
+
const catalogue = catalogueIds(results['or.models']);
|
|
1574
|
+
for (const id of chain) {
|
|
1575
|
+
modelChecks.push(assessChainModel(id, {
|
|
1576
|
+
catalogue,
|
|
1577
|
+
primary: id === model.model,
|
|
1578
|
+
offline,
|
|
1579
|
+
probed,
|
|
1580
|
+
configuredVar: env.OPENROUTER_CODEGEN_MODEL ? 'OPENROUTER_CODEGEN_MODEL' : (id === model.model ? 'OPENROUTER_CODEGEN_MODEL' : 'ACUVO_FALLBACK_MODELS'),
|
|
1581
|
+
}));
|
|
1582
|
+
}
|
|
1583
|
+
sections.push({ id: 'model', title: `Model chain (${chain.length} deep · default ${DEFAULT_MODEL})`, checks: modelChecks });
|
|
1584
|
+
|
|
1585
|
+
/**
|
|
1586
|
+
* ⭐ A PROPERTY OF THE MODEL, SO IT SITS WITH THE MODEL. Whether the prefix
|
|
1587
|
+
* caches decides what a long session costs, and it is knowable for free.
|
|
1588
|
+
*/
|
|
1589
|
+
sections.push({ id: 'cache', title: 'Prompt cache', checks: cacheChecks({ model: model.model, maxRounds }) });
|
|
1590
|
+
|
|
1591
|
+
// ── MEDIA ────────────────────────────────────────────────────────────────
|
|
1592
|
+
const mediaChecks = [];
|
|
1593
|
+
for (const t of mediaTargets) {
|
|
1594
|
+
if (!t.url) {
|
|
1595
|
+
/**
|
|
1596
|
+
* ⚠️ THE SECRET, NOT THE URL — see `builtIn` above. `explicitlyOff` is the
|
|
1597
|
+
* third case and it must not be told to set a variable it already set: an
|
|
1598
|
+
* empty `MODAL_TTS_URL=` is a deliberate opt-out on an air-gapped machine,
|
|
1599
|
+
* and `media.mjs` honours it on purpose.
|
|
1600
|
+
*/
|
|
1601
|
+
const explicitlyOff = t.envVar in env && String(env[t.envVar] ?? '').trim() === '';
|
|
1602
|
+
mediaChecks.push({
|
|
1603
|
+
id: `media.${t.id}`,
|
|
1604
|
+
label: t.label,
|
|
1605
|
+
state: 'dark',
|
|
1606
|
+
verified: true,
|
|
1607
|
+
detail: explicitlyOff
|
|
1608
|
+
? `${t.envVar} is set to an empty value, which means OFF, so ${t.label} is never offered to the model`
|
|
1609
|
+
: (t.builtIn
|
|
1610
|
+
? `no media credential is set, so ${t.label} is never offered to the model (its URL is built in — that part needs nothing from you)`
|
|
1611
|
+
: `${t.envVar} is unset, so ${t.label} is never offered to the model`),
|
|
1612
|
+
fix: explicitlyOff
|
|
1613
|
+
? `remove the empty ${t.envVar}, or give it a URL, to turn ${t.label} back on`
|
|
1614
|
+
: (t.builtIn
|
|
1615
|
+
? 'set ACUVO_MEDIA_SECRET in .env.local — ONE credential turns on speech, transcription, documents and table reading together'
|
|
1616
|
+
: `set ${t.envVar} to your endpoint URL (and MODAL_VIDEO_SECRET to the value it expects)`),
|
|
1617
|
+
});
|
|
1618
|
+
continue;
|
|
1619
|
+
}
|
|
1620
|
+
const host = hostOf(t.url);
|
|
1621
|
+
if (!host) {
|
|
1622
|
+
mediaChecks.push({
|
|
1623
|
+
id: `media.${t.id}`,
|
|
1624
|
+
label: t.label,
|
|
1625
|
+
state: 'broken',
|
|
1626
|
+
verified: true,
|
|
1627
|
+
detail: `${t.envVar} is set but is not a valid URL`,
|
|
1628
|
+
fix: `set ${t.envVar} to a full https:// URL`,
|
|
1629
|
+
});
|
|
1630
|
+
continue;
|
|
1631
|
+
}
|
|
1632
|
+
/**
|
|
1633
|
+
* ⚠️ THE SECRET IS CHECKED BEFORE THE WIRE, because this is the exact
|
|
1634
|
+
* configuration that cost the hour: a URL that is set, a secret that is
|
|
1635
|
+
* not, four tools OFFERED to the model, and every call answering 200 with
|
|
1636
|
+
* a refusal the caller read as transient.
|
|
1637
|
+
*/
|
|
1638
|
+
const probe = probed
|
|
1639
|
+
? (results[`media.${t.id}`] ?? { kind: 'unreachable', detail: 'not probed' })
|
|
1640
|
+
: { kind: 'unchecked', detail: 'no network probe was requested' };
|
|
1641
|
+
const verdict = assessProbe(probe, { envVar: t.envVar, offline, host });
|
|
1642
|
+
if (!media.secret && verdict.state === 'live' && verdict.verified) {
|
|
1643
|
+
// Belt and braces: if a service ever stops requiring the secret we still
|
|
1644
|
+
// say the variable is missing, because three of the four do require it.
|
|
1645
|
+
verdict.detail += ' — note MODAL_VIDEO_SECRET is unset';
|
|
1646
|
+
}
|
|
1647
|
+
mediaChecks.push({ id: `media.${t.id}`, label: t.label, ...verdict });
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
mediaChecks.push(assessImage(image, results.image, offline, probed, env));
|
|
1651
|
+
sections.push({ id: 'media', title: 'Media services', checks: mediaChecks });
|
|
1652
|
+
|
|
1653
|
+
// ── TOOLS ────────────────────────────────────────────────────────────────
|
|
1654
|
+
const offer = toolOffer({ root, env, allowRun, maxRounds });
|
|
1655
|
+
const toolChecks = [{
|
|
1656
|
+
id: 'tools.offer',
|
|
1657
|
+
label: 'tools offered here',
|
|
1658
|
+
state: 'live',
|
|
1659
|
+
verified: true,
|
|
1660
|
+
detail: `${offer.offered.length} of ${offer.total}${allowRun ? '' : ' (--no-run)'}${maxRounds <= 1 ? ' (single-shot)' : ''}`,
|
|
1661
|
+
fix: null,
|
|
1662
|
+
}];
|
|
1663
|
+
for (const w of offer.withheld) {
|
|
1664
|
+
toolChecks.push({ id: `tools.withheld.${w.name}`, label: w.name, state: 'dark', verified: true, detail: w.why, fix: w.fix });
|
|
1665
|
+
}
|
|
1666
|
+
sections.push({ id: 'tools', title: 'Tool surface', checks: toolChecks });
|
|
1667
|
+
|
|
1668
|
+
/**
|
|
1669
|
+
* ⭐ THE OTHER HALF OF THE TOOL SURFACE, AND THE HALF WE DID NOT WRITE. An
|
|
1670
|
+
* MCP server is how a user's Linear / Postgres / Sentry becomes reachable, so
|
|
1671
|
+
* "is it connected" belongs directly under "what tools exist here".
|
|
1672
|
+
*
|
|
1673
|
+
* ⚠️ READ ONCE, USED TWICE — the checks below, and the scrub at the very end.
|
|
1674
|
+
* A config file is allowed to contain a real credential, and this is the one
|
|
1675
|
+
* secret source that is NOT an environment variable, so SECRET_ENV_VARS would
|
|
1676
|
+
* never have caught it.
|
|
1677
|
+
*/
|
|
1678
|
+
const mcp = mcpConfigImpl(root);
|
|
1679
|
+
/**
|
|
1680
|
+
* ⚠️ `offeredCount` IS PASSED SO THE TWO ROWS CANNOT DISAGREE. This line used
|
|
1681
|
+
* to let `mcpChecks` quote `TOOL_NAMES.length` while the row above printed
|
|
1682
|
+
* `offer.offered.length` — 47 against 34, two lines apart in one report.
|
|
1683
|
+
*/
|
|
1684
|
+
/**
|
|
1685
|
+
* ⭐ THE CATALOGUE RIDES IN THE SAME SECTION, DELIBERATELY. A separate
|
|
1686
|
+
* "MCP catalogue" heading would be a second place to look for one subject,
|
|
1687
|
+
* and the two answers belong side by side: what you HAVE configured, then
|
|
1688
|
+
* what you could have. See `mcpCatalogueChecks` for why only servers we ran
|
|
1689
|
+
* ourselves appear.
|
|
1690
|
+
*/
|
|
1691
|
+
sections.push({
|
|
1692
|
+
id: 'mcp',
|
|
1693
|
+
title: 'MCP servers',
|
|
1694
|
+
checks: [
|
|
1695
|
+
...mcpChecks(mcp, env, { offeredCount: offer.offered.length }),
|
|
1696
|
+
...mcpCatalogueChecks({ env, root, declaredNames: (mcp?.servers ?? []).map((s) => s.name) }),
|
|
1697
|
+
],
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1700
|
+
/**
|
|
1701
|
+
* ── ⭐ WHAT THIS AGENT MAY RUN, AND WHAT IT COULD RUN ──────────────────────
|
|
1702
|
+
*
|
|
1703
|
+
* The write→run→fix loop IS the product, so "can it run your test suite" is
|
|
1704
|
+
* the single most consequential fact about a workspace — and it was the one
|
|
1705
|
+
* thing doctor did not say. The polyglot presets are built, tested and wired;
|
|
1706
|
+
* they are simply OFF until someone opts in, and a capability nobody knows
|
|
1707
|
+
* about is worth what an absent one is worth.
|
|
1708
|
+
*
|
|
1709
|
+
* ⚠️ THE REFUSAL ALREADY TEACHES THIS — it names the preset, the exact JSON
|
|
1710
|
+
* and the env var — but only AFTER the model has burned a round discovering
|
|
1711
|
+
* it. This is the same sentence, before the round is spent.
|
|
1712
|
+
*
|
|
1713
|
+
* ⚠️ NEVER PRESENTED AS BROKEN. Four binaries is the deliberate default
|
|
1714
|
+
* posture, not a fault; `state: 'live'` with the presets listed as available
|
|
1715
|
+
* says "this is what you have and here is more", which is true. Marking it
|
|
1716
|
+
* dark would train people to fix something that is working as designed.
|
|
1717
|
+
*/
|
|
1718
|
+
sections.push({ id: 'commands', title: 'What it may run', checks: commandChecks(root, env, readFileImpl) });
|
|
1719
|
+
|
|
1720
|
+
// ── WORKSPACE ────────────────────────────────────────────────────────────
|
|
1721
|
+
sections.push({ id: 'workspace', title: 'Workspace', checks: await workspaceChecks(root, gitStatusImpl, readFileImpl, gitTimeoutMs) });
|
|
1722
|
+
|
|
1723
|
+
const flat = sections.flatMap((s) => s.checks);
|
|
1724
|
+
const summary = summarise(flat);
|
|
1725
|
+
|
|
1726
|
+
const report = {
|
|
1727
|
+
ok: summary.broken === 0,
|
|
1728
|
+
generatedAt: now(),
|
|
1729
|
+
root,
|
|
1730
|
+
platform,
|
|
1731
|
+
node: nodeVersion,
|
|
1732
|
+
probed,
|
|
1733
|
+
offline,
|
|
1734
|
+
summary,
|
|
1735
|
+
sections,
|
|
1736
|
+
};
|
|
1737
|
+
/**
|
|
1738
|
+
* ⚠️⚠️ THE LAST THING THAT HAPPENS, AND IT IS NOT OPTIONAL. Everything above
|
|
1739
|
+
* is written not to hold a credential; this is the guarantee that a service
|
|
1740
|
+
* echoing one back at us cannot launder it into the report anyway.
|
|
1741
|
+
*
|
|
1742
|
+
* ⚠️ AND THE ENVIRONMENT IS NO LONGER THE ONLY PLACE A SECRET LIVES. An
|
|
1743
|
+
* `mcp.json` env block routinely holds a real API token typed in by hand —
|
|
1744
|
+
* a source `SECRET_ENV_VARS` cannot know about, because these names are the
|
|
1745
|
+
* user's, not ours. Placeholders are excluded on purpose: `${GITHUB_TOKEN}`
|
|
1746
|
+
* is not a credential, and redacting it would delete the very text the
|
|
1747
|
+
* placeholder line needs in order to explain itself.
|
|
1748
|
+
*/
|
|
1749
|
+
const scrubEnv = { ...env };
|
|
1750
|
+
const scrubNames = [...SECRET_ENV_VARS];
|
|
1751
|
+
if (mcp?.ok) {
|
|
1752
|
+
for (const s of mcp.servers ?? []) {
|
|
1753
|
+
for (const [k, v] of Object.entries(s.env ?? {})) {
|
|
1754
|
+
if (typeof v !== 'string' || envPlaceholderName(v)) continue;
|
|
1755
|
+
const name = `mcp:${s.name}:${k}`;
|
|
1756
|
+
scrubEnv[name] = v;
|
|
1757
|
+
scrubNames.push(name);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
return scrubSecrets(report, scrubEnv, scrubNames);
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
// ── the small assessors, kept separate so each is readable ─────────────────
|
|
1765
|
+
|
|
1766
|
+
function safeChain(primary, env) {
|
|
1767
|
+
try {
|
|
1768
|
+
const c = buildChain(primary, env);
|
|
1769
|
+
return Array.isArray(c) && c.length ? c : [primary];
|
|
1770
|
+
} catch {
|
|
1771
|
+
return [primary];
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
function catalogueIds(probe) {
|
|
1776
|
+
if (!probe || probe.kind || !probe.json) return null;
|
|
1777
|
+
const data = probe.json?.data;
|
|
1778
|
+
if (!Array.isArray(data)) return null;
|
|
1779
|
+
return new Set(data.map((m) => m?.id).filter(Boolean));
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
function assessKey(probe, offline, probed) {
|
|
1783
|
+
const base = { id: 'model.key', label: 'OPENROUTER_API_KEY' };
|
|
1784
|
+
if (!probed) {
|
|
1785
|
+
return { ...base, state: 'live', verified: false, detail: 'present — not checked (no network probe was requested)', fix: null };
|
|
1786
|
+
}
|
|
1787
|
+
if (probe?.kind === 'unreachable') {
|
|
1788
|
+
return offline
|
|
1789
|
+
? { ...base, state: 'live', verified: false, detail: 'present — could not check (nothing on this machine reached the network)', fix: 'reconnect and re-run the doctor to verify OPENROUTER_API_KEY' }
|
|
1790
|
+
: { ...base, state: 'broken', verified: true, detail: 'present, but openrouter.ai could not be reached while other hosts answered', fix: 'check outbound access to openrouter.ai, then re-check OPENROUTER_API_KEY' };
|
|
1791
|
+
}
|
|
1792
|
+
if (probe?.status === 401 || probe?.status === 403) {
|
|
1793
|
+
/**
|
|
1794
|
+
* ⭐⭐ PRESENT AND REVOKED IS THE WORST CASE, and "present" is what every
|
|
1795
|
+
* other check in this package tests for. A key that exists and authenticates
|
|
1796
|
+
* nothing passes `configured`, passes the offer gate, and fails every call.
|
|
1797
|
+
*/
|
|
1798
|
+
return { ...base, state: 'broken', verified: true, detail: `present, but it does NOT authenticate — the API answered HTTP ${probe.status}. The key is revoked, mistyped, or from another account.`, fix: 'replace OPENROUTER_API_KEY with a working key from https://openrouter.ai/keys' };
|
|
1799
|
+
}
|
|
1800
|
+
if (probe?.status >= 400) {
|
|
1801
|
+
return { ...base, state: 'broken', verified: true, detail: `present, but the key endpoint answered HTTP ${probe.status}`, fix: 'retry; if it persists, replace OPENROUTER_API_KEY from https://openrouter.ai/keys' };
|
|
1802
|
+
}
|
|
1803
|
+
return { ...base, state: 'live', verified: true, detail: 'present, and it authenticates', fix: null };
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
/**
|
|
1807
|
+
* ⭐ ZERO CREDITS IS BROKEN EVEN THOUGH THE KEY IS VALID. Measured on this
|
|
1808
|
+
* project before: a key that authenticates against an exhausted balance returns
|
|
1809
|
+
* HTTP 402 on every paid model, and the chain spends four attempts discovering
|
|
1810
|
+
* it. "Your key is fine" is a true sentence that sends someone the wrong way.
|
|
1811
|
+
*/
|
|
1812
|
+
function assessCredits(probe, offline, probed, keyState) {
|
|
1813
|
+
const base = { id: 'model.credits', label: 'account balance' };
|
|
1814
|
+
if (!probed) return { ...base, state: 'live', verified: false, detail: 'not checked (no network probe was requested)', fix: null };
|
|
1815
|
+
if (probe?.kind === 'unreachable') {
|
|
1816
|
+
return offline
|
|
1817
|
+
? { ...base, state: 'live', verified: false, detail: 'could not check — nothing on this machine reached the network', fix: 'reconnect and re-run the doctor' }
|
|
1818
|
+
: null;
|
|
1819
|
+
}
|
|
1820
|
+
const d = probe?.json?.data;
|
|
1821
|
+
if (!d || typeof d.total_credits !== 'number') {
|
|
1822
|
+
// The key line already carries the failure; a second red line about the
|
|
1823
|
+
// same cause is noise, not diagnosis.
|
|
1824
|
+
return keyState === 'broken' ? null : { ...base, state: 'live', verified: false, detail: 'the balance could not be read', fix: null };
|
|
1825
|
+
}
|
|
1826
|
+
const left = d.total_credits - (typeof d.total_usage === 'number' ? d.total_usage : 0);
|
|
1827
|
+
if (left <= 0) {
|
|
1828
|
+
return { ...base, state: 'broken', verified: true, detail: `$${left.toFixed(2)} remaining — the key authenticates but every paid model will answer HTTP 402`, fix: 'top up at https://openrouter.ai/credits, or set OPENROUTER_CODEGEN_MODEL to a :free model' };
|
|
1829
|
+
}
|
|
1830
|
+
return { ...base, state: 'live', verified: true, detail: `$${left.toFixed(2)} remaining of $${d.total_credits.toFixed(2)}`, fix: null };
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
function assessChainModel(id, { catalogue, primary, offline, probed, configuredVar }) {
|
|
1834
|
+
const base = { id: `model.chain.${id}`, label: id + (primary ? ' (primary)' : '') };
|
|
1835
|
+
if (!probed || catalogue === null) {
|
|
1836
|
+
const why = !probed ? 'not checked (no network probe was requested)'
|
|
1837
|
+
: offline ? 'could not check — nothing on this machine reached the network'
|
|
1838
|
+
: 'could not read OpenRouter\'s model catalogue';
|
|
1839
|
+
return { ...base, state: 'live', verified: false, detail: why, fix: null };
|
|
1840
|
+
}
|
|
1841
|
+
if (!catalogue.has(id)) {
|
|
1842
|
+
/**
|
|
1843
|
+
* ⚠️ A RETIRED ID IS A SILENT OUTAGE. It is the failure that sat in the
|
|
1844
|
+
* OpenCode integration for weeks: every request answered 404 and three
|
|
1845
|
+
* healthy fallbacks were never tried.
|
|
1846
|
+
*/
|
|
1847
|
+
return {
|
|
1848
|
+
...base,
|
|
1849
|
+
state: 'broken',
|
|
1850
|
+
verified: true,
|
|
1851
|
+
detail: 'not in OpenRouter\'s catalogue — every call to this id will 404',
|
|
1852
|
+
fix: primary
|
|
1853
|
+
? `set OPENROUTER_CODEGEN_MODEL to a live model id (or unset it to fall back to ${DEFAULT_MODEL})`
|
|
1854
|
+
: `remove "${id}" from ${configuredVar}`,
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1857
|
+
return { ...base, state: 'live', verified: true, detail: 'in OpenRouter\'s catalogue', fix: null };
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
/**
|
|
1861
|
+
* ── ⚠️⚠️ THIS LINE NAMED A HOST THE RUN NEVER CONTACTS ──────────────────────
|
|
1862
|
+
*
|
|
1863
|
+
* It read `configured (…acuvo-perchance-images-serve.modal.run, the built-in
|
|
1864
|
+
* default)` and that host is exactly the one that does NOT get called in the
|
|
1865
|
+
* default configuration. Read `generateThroughProviders` (lib/imagegen.mjs:686+)
|
|
1866
|
+
* and the chain is:
|
|
1867
|
+
*
|
|
1868
|
+
* 1. our own GPU engine — only when `engineConfig().configured`, i.e. a URL
|
|
1869
|
+
* AND ACUVO_IMAGE_SECRET. It fails shut otherwise (imagegen.mjs:334-339).
|
|
1870
|
+
* 2. `useOwnServer = Boolean(env[PERCHANCE_IMAGE_URL])` — and when that
|
|
1871
|
+
* variable is UNSET the code takes `generateViaNativePerchance`, which
|
|
1872
|
+
* talks to perchance.org directly. `DEFAULT_IMAGE_URL` is the value used
|
|
1873
|
+
* when someone POINTS AT OUR SERVICE, which is the one case where
|
|
1874
|
+
* `usingDefault` is false.
|
|
1875
|
+
* 3. Pollinations, always, as the fallback.
|
|
1876
|
+
*
|
|
1877
|
+
* So the old sentence printed the built-in default precisely in the branch that
|
|
1878
|
+
* bypasses it — and it did so because the destination was DERIVED TWICE, once
|
|
1879
|
+
* by `imageConfig().base` here and once by `useOwnServer` there. This repo has
|
|
1880
|
+
* a memory entry for exactly this shape ("THE REGISTRY NAMES THE WRONG
|
|
1881
|
+
* TRANSPORT — musetalk gated on RunPod while MODAL serves it"). Deriving the
|
|
1882
|
+
* same fact in two places is what produces the drift; the fix is to read it off
|
|
1883
|
+
* the same predicate the run uses.
|
|
1884
|
+
*
|
|
1885
|
+
* ⚠️ THIS MATTERS MOST TO THE READER IT WAS WRITTEN FOR. ENTERPRISE.md §2.2
|
|
1886
|
+
* calls `generate_image`'s egress "easy to miss"; an egress reviewer who
|
|
1887
|
+
* allow-lists the host this line named would still see the prompt leave to
|
|
1888
|
+
* perchance.org and pollinations.ai. A doctor that misstates a destination is
|
|
1889
|
+
* worse than one that says nothing about it.
|
|
1890
|
+
*
|
|
1891
|
+
* ⚠️ THE PROBE STILL TARGETS `image.base`/health (see the `push('image', …)`
|
|
1892
|
+
* call), so on a default configuration it verifies a host the run will not use.
|
|
1893
|
+
* That is left alone deliberately — changing what the doctor probes is a
|
|
1894
|
+
* behaviour change on the network path — but the wording below no longer lets
|
|
1895
|
+
* a reader mistake the probed host for the destination.
|
|
1896
|
+
*/
|
|
1897
|
+
function imageDestination(image, env) {
|
|
1898
|
+
const engine = engineConfig(env);
|
|
1899
|
+
if (engine.configured) {
|
|
1900
|
+
const h = hostOf(engine.base);
|
|
1901
|
+
return h ? `${h} — our own GPU engine, which the chain tries FIRST` : null;
|
|
1902
|
+
}
|
|
1903
|
+
const useOwnServer = Boolean((env?.[IMAGE_URL_ENV] || '').trim());
|
|
1904
|
+
if (useOwnServer) {
|
|
1905
|
+
const h = hostOf(image.base);
|
|
1906
|
+
return h ? `${h}, the image service you named in ${IMAGE_URL_ENV}` : null;
|
|
1907
|
+
}
|
|
1908
|
+
return `perchance.org direct, then image.pollinations.ai — ${IMAGE_URL_ENV} is unset, so neither our GPU engine (no ${ENGINE_SECRET_ENV}) nor ${hostOf(image.base) || 'the built-in service'} is contacted`;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
function assessImage(image, probe, offline, probed, env = {}) {
|
|
1912
|
+
const base = { id: 'media.generate_image', label: 'generate_image' };
|
|
1913
|
+
if (!image.configured) {
|
|
1914
|
+
return { ...base, state: 'dark', verified: true, detail: `${IMAGE_URL_ENV} is set to an empty value, which means OFF`, fix: `unset ${IMAGE_URL_ENV} to use the default endpoint, or set it to your own image service` };
|
|
1915
|
+
}
|
|
1916
|
+
const dest = imageDestination(image, env);
|
|
1917
|
+
const where = dest ? ` (${dest})` : '';
|
|
1918
|
+
if (!probed) return { ...base, state: 'live', verified: false, detail: `configured${where} · not checked (no network probe was requested)`, fix: null };
|
|
1919
|
+
if (probe?.kind === 'unreachable') {
|
|
1920
|
+
return offline
|
|
1921
|
+
? { ...base, state: 'live', verified: false, detail: `configured${where} · could not check — nothing on this machine reached the network`, fix: `reconnect and re-run the doctor to verify ${IMAGE_URL_ENV}` }
|
|
1922
|
+
: { ...base, state: 'broken', verified: true, detail: `configured${where} · unreachable while other services answered`, fix: `check ${IMAGE_URL_ENV}` };
|
|
1923
|
+
}
|
|
1924
|
+
/**
|
|
1925
|
+
* ⚠️ A 404 ON /health IS NOT A DEAD SERVICE. Older deployments of this image
|
|
1926
|
+
* server had no health route at all, and condemning them would be a check
|
|
1927
|
+
* failing correct work — the mistake this repo has now made six times.
|
|
1928
|
+
*/
|
|
1929
|
+
if (probe?.status === 404) return { ...base, state: 'live', verified: false, detail: `configured${where} · this deployment has no /health route, so it could not be verified`, fix: null };
|
|
1930
|
+
/**
|
|
1931
|
+
* ⚠️ ONLY AN ANSWER THAT READS AS AN AUTH FAILURE NAMES THE TOKEN. The first
|
|
1932
|
+
* draft treated ANY `ok:false` as a credential problem, which sent a reader
|
|
1933
|
+
* to set `PERCHANCE_IMAGE_TOKEN` for a service that was complaining about
|
|
1934
|
+
* something else entirely — a fix that names the wrong variable is worse than
|
|
1935
|
+
* no fix, because it is followed.
|
|
1936
|
+
*/
|
|
1937
|
+
if (probe?.status === 401 || probe?.status === 403 || (probe?.json?.ok === false && readsAsAuthFailure(probe?.json?.error))) {
|
|
1938
|
+
return { ...base, state: 'broken', verified: true, detail: `configured${where} · the service refused the credential`, fix: `set ${IMAGE_TOKEN_ENV} to the value the image service expects (it is that service's SHARED_TOKEN — same value, two names)` };
|
|
1939
|
+
}
|
|
1940
|
+
if (probe?.status >= 400) return { ...base, state: 'broken', verified: true, detail: `configured${where} · HTTP ${probe.status}`, fix: `check ${IMAGE_URL_ENV} points at a running image service` };
|
|
1941
|
+
if (probe?.json?.ok === false) {
|
|
1942
|
+
return { ...base, state: 'broken', verified: true, detail: `configured${where} · the health route answered: ${String(probe.json.error ?? 'no reason given').slice(0, 120)}`, fix: `check ${IMAGE_URL_ENV} points at a healthy image service` };
|
|
1943
|
+
}
|
|
1944
|
+
const browser = probe?.json?.browser ? ` — browser ${probe.json.browser}` : '';
|
|
1945
|
+
return { ...base, state: 'live', verified: true, detail: `configured${where} · reachable${browser}`, fix: null };
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
const GIT_TIMED_OUT = Symbol('git-timed-out');
|
|
1949
|
+
|
|
1950
|
+
/**
|
|
1951
|
+
* What `run_command` will actually accept in THIS workspace, and what it could.
|
|
1952
|
+
*
|
|
1953
|
+
* ⚠️ It asks the real resolver rather than restating the rules. `command.mjs`
|
|
1954
|
+
* owns what an allowlist is; a second opinion here would be the copy that goes
|
|
1955
|
+
* stale the day someone adds a preset — the same argument search.mjs makes for
|
|
1956
|
+
* importing the credential list instead of re-declaring it.
|
|
1957
|
+
*/
|
|
1958
|
+
function commandChecks(root, env, readFileImpl) {
|
|
1959
|
+
let configText = null;
|
|
1960
|
+
try {
|
|
1961
|
+
configText = readFileImpl(join(root, COMMANDS_CONFIG_FILE), 'utf8');
|
|
1962
|
+
} catch {
|
|
1963
|
+
// Absent is the overwhelmingly common case and means "the default four".
|
|
1964
|
+
configText = null;
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
const resolved = resolveCommandAllowlist({ configText, envValue: env[ALLOW_COMMANDS_ENV] });
|
|
1968
|
+
|
|
1969
|
+
if (!resolved.ok) {
|
|
1970
|
+
return [{
|
|
1971
|
+
id: 'commands.config',
|
|
1972
|
+
label: 'command allowlist',
|
|
1973
|
+
state: 'broken',
|
|
1974
|
+
verified: true,
|
|
1975
|
+
detail: resolved.error,
|
|
1976
|
+
// ⚠️ A malformed control stops commands rather than silently reverting to
|
|
1977
|
+
// the default — so this is genuinely broken, not merely unset.
|
|
1978
|
+
fix: `fix or delete ${COMMANDS_CONFIG_FILE} — a config that cannot be parsed stops every command rather than falling back`,
|
|
1979
|
+
}];
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
const binaries = [...(resolved.allowlist?.binaries ?? [])];
|
|
1983
|
+
const on = resolved.presets ?? [];
|
|
1984
|
+
const off = PRESET_NAMES.filter((p) => !on.includes(p));
|
|
1985
|
+
|
|
1986
|
+
const checks = [{
|
|
1987
|
+
id: 'commands.allowed',
|
|
1988
|
+
label: 'programs it may run',
|
|
1989
|
+
state: 'live',
|
|
1990
|
+
verified: true,
|
|
1991
|
+
detail: binaries.join(', ') + (on.length ? ` · presets on: ${on.join(', ')}` : ' · no presets enabled'),
|
|
1992
|
+
fix: null,
|
|
1993
|
+
}];
|
|
1994
|
+
|
|
1995
|
+
if (off.length) {
|
|
1996
|
+
checks.push({
|
|
1997
|
+
id: 'commands.presets',
|
|
1998
|
+
label: 'presets available',
|
|
1999
|
+
state: 'dark',
|
|
2000
|
+
verified: true,
|
|
2001
|
+
/**
|
|
2002
|
+
* ⭐ THE SENTENCE THAT MATTERS. Someone whose repo is Python sees, before
|
|
2003
|
+
* spending a round on a refusal, that the loop CAN run their tests.
|
|
2004
|
+
*/
|
|
2005
|
+
detail: `${off.join(', ')} — off by default, so pytest / go test / cargo test are refused here`,
|
|
2006
|
+
fix: `add {"presets": ["${off[0]}"]} to ${COMMANDS_CONFIG_FILE}, or set ${ALLOW_COMMANDS_ENV}=${off[0]}`,
|
|
2007
|
+
});
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
return checks;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
async function workspaceChecks(root, gitStatusImpl, readFileImpl, gitTimeoutMs) {
|
|
2014
|
+
let status;
|
|
2015
|
+
try {
|
|
2016
|
+
status = await withTimeout(
|
|
2017
|
+
Promise.resolve().then(() => gitStatusImpl(root)),
|
|
2018
|
+
gitTimeoutMs,
|
|
2019
|
+
GIT_TIMED_OUT,
|
|
2020
|
+
);
|
|
2021
|
+
if (status === GIT_TIMED_OUT) {
|
|
2022
|
+
return [{
|
|
2023
|
+
id: 'workspace.git',
|
|
2024
|
+
label: 'git',
|
|
2025
|
+
state: 'dark',
|
|
2026
|
+
verified: true,
|
|
2027
|
+
detail: `git did not answer within ${gitTimeoutMs}ms — the doctor stopped waiting rather than hang`,
|
|
2028
|
+
fix: 'check for a stale index.lock, or a filesystem/credential helper that is blocking git',
|
|
2029
|
+
}];
|
|
2030
|
+
}
|
|
2031
|
+
} catch (err) {
|
|
2032
|
+
return [{
|
|
2033
|
+
id: 'workspace.git',
|
|
2034
|
+
label: 'git',
|
|
2035
|
+
state: 'dark',
|
|
2036
|
+
verified: true,
|
|
2037
|
+
detail: `git could not be run here — ${err instanceof Error ? err.message : String(err)}`,
|
|
2038
|
+
fix: 'install git (git_status / git_diff / git_log / git_commit refuse until then)',
|
|
2039
|
+
}];
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
if (!status?.ok) {
|
|
2043
|
+
return [{
|
|
2044
|
+
id: 'workspace.git',
|
|
2045
|
+
label: 'git',
|
|
2046
|
+
state: 'dark',
|
|
2047
|
+
verified: true,
|
|
2048
|
+
detail: String(status?.error ?? 'not a git repository').slice(0, 240),
|
|
2049
|
+
fix: 'run `git init` here if you want git_status / git_diff / git_log / git_commit to work',
|
|
2050
|
+
}];
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
const branch = status.branch || '(no commits yet)';
|
|
2054
|
+
const count = Array.isArray(status.files) ? status.files.length : 0;
|
|
2055
|
+
const checks = [{
|
|
2056
|
+
id: 'workspace.git',
|
|
2057
|
+
label: 'git',
|
|
2058
|
+
state: 'live',
|
|
2059
|
+
verified: true,
|
|
2060
|
+
/**
|
|
2061
|
+
* ⚠️ A DIRTY TREE IS INFORMATION, NOT A FAULT. Flagging uncommitted work as
|
|
2062
|
+
* broken would paint every real working session red, and a doctor people
|
|
2063
|
+
* learn to ignore is worse than no doctor.
|
|
2064
|
+
*/
|
|
2065
|
+
detail: `repository on ${branch} · ${count === 0 ? 'clean' : `${count} file${count === 1 ? '' : 's'} changed`}`,
|
|
2066
|
+
fix: null,
|
|
2067
|
+
}];
|
|
2068
|
+
|
|
2069
|
+
let ignore = null;
|
|
2070
|
+
try { ignore = readFileImpl(join(root, '.gitignore'), 'utf8'); } catch { ignore = null; }
|
|
2071
|
+
const covered = gitignoreCoversAcuvo(typeof ignore === 'string' ? ignore : null);
|
|
2072
|
+
checks.push({
|
|
2073
|
+
id: 'workspace.gitignore',
|
|
2074
|
+
label: `${ACUVO_DIR}/ ignored`,
|
|
2075
|
+
state: covered ? 'live' : 'dark',
|
|
2076
|
+
verified: true,
|
|
2077
|
+
detail: covered
|
|
2078
|
+
? `${ACUVO_DIR}/ is ignored, so sessions and audit logs stay out of your commits`
|
|
2079
|
+
: `${ACUVO_DIR}/ is NOT ignored — this CLI writes sessions and audit logs there, inside your repository`,
|
|
2080
|
+
fix: covered ? null : `add "${ACUVO_DIR}/" to .gitignore`,
|
|
2081
|
+
});
|
|
2082
|
+
return checks;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
2086
|
+
// PURE: THE RENDERING
|
|
2087
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
2088
|
+
|
|
2089
|
+
const NO_PAINT = { dim: (t) => t, bold: (t) => t, gold: (t) => t, green: (t) => t, red: (t) => t, cyan: (t) => t };
|
|
2090
|
+
|
|
2091
|
+
/**
|
|
2092
|
+
* ── ⭐ THE OUTPUT IS THE PRODUCT ────────────────────────────────────────────
|
|
2093
|
+
*
|
|
2094
|
+
* Three words in a fixed column so the eye can scan them, and — the part that
|
|
2095
|
+
* matters — a `→` line under every dark or broken check carrying the exact
|
|
2096
|
+
* variable or command that fixes it. A reader should never have to leave this
|
|
2097
|
+
* screen to know what to do next.
|
|
2098
|
+
*
|
|
2099
|
+
* ⚠️ COLOUR IS OPT-IN AND REINFORCES A SIGNAL THAT IS ALREADY LEGIBLE. The
|
|
2100
|
+
* state words are distinct without it, so a redirected file and a colour-blind
|
|
2101
|
+
* reader lose nothing — the rule colour.mjs sets out for the rest of the CLI.
|
|
2102
|
+
*/
|
|
2103
|
+
export function formatDoctor(report, { paint = null } = {}) {
|
|
2104
|
+
const p = { ...NO_PAINT, ...(paint ?? {}) };
|
|
2105
|
+
const r = report ?? {};
|
|
2106
|
+
const sections = Array.isArray(r.sections) ? r.sections : [];
|
|
2107
|
+
const lines = [];
|
|
2108
|
+
|
|
2109
|
+
lines.push(p.bold('Acuvo Code — doctor'));
|
|
2110
|
+
const head = [r.root, r.node ? `node ${r.node}` : null, r.platform].filter(Boolean).join(' · ');
|
|
2111
|
+
if (head) lines.push(p.dim(head));
|
|
2112
|
+
if (r.probed === false) lines.push(p.dim('network probes were not run — every remote line below is configuration only'));
|
|
2113
|
+
else if (r.offline) lines.push(p.dim('nothing reached the network: remote lines are marked unverified rather than broken'));
|
|
2114
|
+
lines.push('');
|
|
2115
|
+
|
|
2116
|
+
const colourFor = (state) => (state === 'live' ? p.green : state === 'broken' ? p.red : p.gold);
|
|
2117
|
+
|
|
2118
|
+
for (const section of sections) {
|
|
2119
|
+
const checks = Array.isArray(section?.checks) ? section.checks : [];
|
|
2120
|
+
if (checks.length === 0) continue;
|
|
2121
|
+
lines.push(p.bold(String(section.title ?? section.id ?? '').toUpperCase()));
|
|
2122
|
+
for (const c of checks) {
|
|
2123
|
+
const mark = c.state === 'live' && c.verified === false ? 'live?' : String(c.state ?? '');
|
|
2124
|
+
const state = colourFor(c.state)(mark.padEnd(7));
|
|
2125
|
+
lines.push(` ${state} ${String(c.label ?? '').padEnd(26)} ${p.dim(String(c.detail ?? ''))}`);
|
|
2126
|
+
if (c.fix) lines.push(` ${' '.repeat(7)} ${' '.repeat(26)} ${p.gold('→')} ${c.fix}`);
|
|
2127
|
+
}
|
|
2128
|
+
lines.push('');
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
const s = r.summary ?? { live: 0, dark: 0, broken: 0, unverified: 0 };
|
|
2132
|
+
lines.push(p.dim(`${s.live} live · ${s.dark} dark · ${s.broken} broken${s.unverified ? ` · ${s.unverified} unverified` : ''}`));
|
|
2133
|
+
lines.push(
|
|
2134
|
+
s.broken === 0
|
|
2135
|
+
? p.green('nothing is broken.')
|
|
2136
|
+
: p.red(`${s.broken} thing${s.broken === 1 ? ' is' : 's are'} broken — each red line above names what fixes it.`),
|
|
2137
|
+
);
|
|
2138
|
+
return lines.join('\n');
|
|
2139
|
+
}
|