From a249b1c4fe2168fb06d7e8e56438dca3428a4957 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Tue, 28 Jul 2026 15:48:18 +0700 Subject: [PATCH 01/16] feat: add `codevhub doctor` to check the environment before installing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users on the internal network discover their machine can't run CoDev only partway through `codevhub install` — as a bare `fetch failed`, after `npm i -g` has already mutated the machine. `codevhub doctor` runs every precondition first, read-only, and explains what to fix. Five groups: environment (Node, npm, global prefix, registry/proxy config, proxy & TLS env, OS cert store), network (backend + npm registry reachability), account (sign-in, API key, config, Supabase), LLM (key, models, and a real one-token completion — the only check that proves inference is permitted), and an informational report on what is already installed. The diagnosis layer is the point of the command. `describeNetworkError` unwraps one level of `err.cause` and only special-cases cert codes, so everything else still reached users as `fetch failed (connect ECONNREFUSED 10.0.0.1:8080)`. `diagnoseError` walks the whole chain — including the AggregateError Node emits per address family — and returns what happened in plain language, the likely cause given this machine's proxy state, the fix, and the raw chain for support. It also diagnoses HTTP responses (407, a proxy-issued 403, 502), which are not exceptions and so were never seen at all, and reproduces npm's stderr in full because it names the registry, proxy and .npmrc in play. Nothing bypasses the logger's redaction. When the network checks fail it offers to re-run everything through a proxy the user types in, then prints the exact export/setx commands to make the working settings permanent. The re-exec is handed to index.tsx rather than run in the component: spawnSync with inherited stdio while Ink owns the TTY corrupts the terminal. Also raises the Node floor from 22.5.0 to 22.21.0 and adds the missing `engines` field. That is not a round number by accident — 22.21.0 is where HTTP_PROXY/HTTPS_PROXY support was backported to the 22 LTS line (nodejs/node#57872). Below it Node silently ignores proxy environment variables, so a user on 22.10 passed the old gate and then could not sign in under any configuration. And teaches the whole CLI to honor proxies: Node needs NODE_USE_ENV_PROXY=1, read at bootstrap, so users who export only the proxy variables get nothing. `lib/proxy.ts` enables it at the entry point via http.setGlobalProxyFromEnv() (verified to route global fetch, not just http.Agent), falling back to a re-exec on older Node. It also flags a NO_PROXY entry covering our own backend — the documented cause of `Login failed`. `install`/`config` gain a pure pre-flight (Node version + proxy env only) so a misconfigured proxy surfaces before the user invests in the wizard. The npm and OS-cert-store checks are deliberately excluded: each npm probe costs ~300ms, and the trust-store read blocks the event loop for 300ms+ on Windows — the stall documented in lib/tls.ts. Login/FetchApiKey now render the full diagnosis for transport errors while leaving precise backend HTTP messages alone. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 42 +- README.md | 61 +- README.npm.md | 35 +- package.json | 3 + src/DoctorApp.tsx | 370 ++++++++ src/SetupApp.tsx | 492 +++++----- src/components/CheckList.tsx | 146 +++ src/components/FetchApiKey.tsx | 23 +- src/components/Login.tsx | 43 +- src/components/ProxyPrompt.tsx | 93 ++ src/index.tsx | 107 ++- src/lib/const.ts | 29 + src/lib/doctor.ts | 1572 +++++++++++++++++++++++++++++++ src/lib/help.ts | 3 + src/lib/log.ts | 8 + src/lib/proxy.ts | 281 ++++++ tests/DoctorApp.test.tsx | 354 +++++++ tests/components/Login.test.tsx | 15 +- tests/lib/const.test.ts | 42 + tests/lib/doctor.test.ts | 735 +++++++++++++++ tests/lib/proxy.test.ts | 222 +++++ 21 files changed, 4429 insertions(+), 247 deletions(-) create mode 100644 src/DoctorApp.tsx create mode 100644 src/components/CheckList.tsx create mode 100644 src/components/ProxyPrompt.tsx create mode 100644 src/lib/doctor.ts create mode 100644 src/lib/proxy.ts create mode 100644 tests/DoctorApp.test.tsx create mode 100644 tests/lib/doctor.test.ts create mode 100644 tests/lib/proxy.test.ts diff --git a/AGENTS.md b/AGENTS.md index 16fd64a..c9bdc03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ The CLI is layered. Each layer has one job and only depends on the layer below i - `src/index.tsx` — argv dispatcher. Maps each command to its app component or logic function and exits. - `src/App.tsx` — command-root Ink components, one per command (`InstallApp`, `UpdateApp`, `UploadApp`). Each is a state machine that wires together components from `src/components/` and orchestrates the command's flow. `index.tsx` mounts these via `render()`. - `src/components/*.tsx` — reusable Ink components (Banner, Frame, Step, TaskList) and command-phase components (Install, Configure, Login, FetchApiKey, Update). Apps and other components import these; they never import apps. -- `src/lib/*.ts` — non-UI logic modules (`auth`, `configure`, `npm`, `paths`, `markdown`, `statistics`, `export`, `upload`, `run`, `restore`, `backend`, `help`, `const`, `reexec`, `supabase`). Components and apps import logic; logic never imports UI. +- `src/lib/*.ts` — non-UI logic modules (`auth`, `configure`, `npm`, `paths`, `markdown`, `statistics`, `export`, `upload`, `run`, `restore`, `backend`, `help`, `const`, `reexec`, `supabase`, `proxy`, `doctor`). Components and apps import logic; logic never imports UI. - `src/providers/*.ts` — agent-specific reader implementations used by `src/lib/export.ts` (one file per agent). When adding a new command: @@ -154,12 +154,50 @@ Node verifies TLS against its **own bundled Mozilla CA snapshot and never consul - **At most one retry per process.** `applySystemCaCertsOnce` returns null once it has run, so a chain that stays untrusted surfaces its error instead of looping. Replay is safe: a TLS handshake fails before any body is sent, and every call site passes a replayable body (string/URLSearchParams/FormData/Buffer), never a stream — keep it that way. - **Merge `default` + `system`, never `bundled` + `system`.** `"default"` already folds in `NODE_EXTRA_CA_CERTS`; narrowing it would silently drop the certs of users who fixed this the documented way. - **Every failure mode is a no-op**, including an empty system store (`setDefaultCACertificates` is then never called, keeping Node's behavior byte-identical). Trust config isn't ours to have opinions about, and a bad merge would break users whose certs already work. -- The APIs need Node ≥22.19/24.5 (our floor is 22.5), hence `tlsApi.supported()`. They're accessed off the default import, never destructured — a named ESM import of a builtin export that doesn't exist is a link-time error on older Node. +- The APIs need Node ≥22.19/24.5, hence `tlsApi.supported()`. Our floor is 22.21, so every supported 22.x is fine — but **24.0–24.4 is not**, which is what keeps the guard load-bearing. They're accessed off the default import, never destructured — a named ESM import of a builtin export that doesn't exist is a link-time error on older Node. `describeNetworkError` unwraps Node's bare `fetch failed` (the real reason hides on `err.cause`) and appends a remedy for cert codes. It exists because **Node's own `--use-system-ca` hint pointedly excludes `SELF_SIGNED_CERT_IN_CHAIN`** — `crypto_common.cc` gates it on `DEPTH_ZERO_SELF_SIGNED_CERT` / `UNABLE_TO_VERIFY_LEAF_SIGNATURE` / `UNABLE_TO_GET_ISSUER_CERT` only, so the corporate-proxy case, the one the hint most exists for, is the one that prints nothing. If a cert error survives the merge, the root isn't in the OS store either, so the hint names `NODE_EXTRA_CA_CERTS` rather than `--use-system-ca`, which would be a dead end. Keep `certHint` **pure** — reading the OS store to word a sentence would put that same Windows stall on the error path, and the retry has already read it. Note the blast radius: this only covers **our own** process. `npm i -g` during install and the agents themselves are separate processes behind the same proxy and need `NODE_EXTRA_CA_CERTS` / npm's `cafile` of their own. +## HTTP proxies + +TLS trust (above) and proxying are **separate problems** with separate fixes; a machine behind a corporate gateway usually has both. + +Node does not honor `HTTP_PROXY`/`HTTPS_PROXY` on its own. Support is gated behind `NODE_USE_ENV_PROXY=1` and read at **bootstrap**, so assigning `process.env` mid-run is too late for the already-initialized global dispatcher. Users who follow the install guide and export only the proxy variables therefore get no proxy at all — silently, with no warning from Node — and every `fetch` dies in the firewall. + +`src/lib/proxy.ts#applyEnvProxy` closes that gap once, from `index.tsx` right after `initLogging` and before dispatch, so `install`, `login`, `upload`, `model` and the launch-time key refresh all benefit rather than just the command being debugged. It no-ops unless a proxy is set and `NODE_USE_ENV_PROXY` is unset, then takes one of two paths: + +1. **`http.setGlobalProxyFromEnv()`** when the running Node has it. Verified empirically to route global `fetch`, not just `node:http`/`https` Agent traffic — which is what makes the fast path viable at all. Probed behind the `httpApi` indirection, same shape as `tlsApi` (off the default import, never destructured). +2. **Re-exec** with `NODE_USE_ENV_PROXY=1` otherwise, via `reexec.ts#spawner`, guarded by a `CODEV_PROXY_APPLIED=1` sentinel so it can never loop. + +`readEither` normalizes each spelling **independently** (`nonEmpty(env.HTTP_PROXY) ?? nonEmpty(env.http_proxy)`). A plain `??` only falls through on `undefined`, so an exported-but-empty `HTTP_PROXY` would mask a perfectly good lowercase one — `tests/lib/proxy.test.ts` pins this. + +`NO_PROXY` is the other half. An entry covering our own backend (internal images ship a blanket `*.viettel.vn`) routes sign-in traffic *around* the proxy and straight into the firewall — the documented cause of `Login failed`. `matchingNoProxyEntry` detects it. The split in responsibility is deliberate: **`index.tsx` only warns** (rewriting the user's environment mid-command is overreach), while **`doctor` strips it in its retry child**, where the user has explicitly asked us to try a proxy. + +Blast radius again: **npm keeps its own proxy and TLS configuration** (`npm config set proxy` / `https-proxy` / `cafile` / `registry`), entirely separate from these variables. A working `codevhub` proves nothing about `npm i -g`, which is why `doctor` checks them separately. + +## `codevhub doctor` + +Pre-flight for everything `install` depends on, so users on a locked-down network find out *before* `npm i -g` mutates their machine. Read-only apart from the diagnostic log — it never installs or configures anything. + +`src/lib/doctor.ts` holds a `Check[]` registry in five groups (`environment`, `network`, `account`, `llm`, `state`), each `run` returning `pass`/`warn`/`fail`/`skip` and never throwing. `DoctorApp` walks the groups; `components/CheckList.tsx` renders them. `Login` is mounted for the sign-in check so `doctor` exercises the real flow, including the paste-back fallback. + +**The diagnosis layer is the point of the command**, not a detail. `lib/tls.ts#describeNetworkError` unwraps one level of `err.cause` and only special-cases cert codes; everything else still reaches the user as `fetch failed (connect ECONNREFUSED 10.0.0.1:8080)`, which tells a non-engineer nothing. `diagnoseError` walks the **whole** chain (including `AggregateError`, which Node emits one entry per address family into) and returns four parts — what happened in plain language, the likely cause *given this machine's proxy state*, the fix, and the raw chain verbatim. Load-bearing details: + +- **A configured-but-ignored proxy supersedes every other explanation** and takes the `fix` slot, because nothing else can be diagnosed until it is fixed. +- **`rootLink` recovers a code from the message text** when `.code` was lost to a re-throw (`"getaddrinfo ENOTFOUND host"`), which is the difference between a real diagnosis and the generic fallback. +- **`AbortSignal.timeout` is special-cased** — its own message names neither host nor duration, so both are rebuilt from the `Attempt`. +- **HTTP responses are diagnosed too** (`diagnoseResponse`). A non-2xx is not an exception, so `describeNetworkError` never sees it — yet 407 and a proxy-issued 403 are exactly what internal users hit. Interceptor headers (`via`, `proxy-authenticate`, …) distinguish "the network blocked this" from "your account lacks access". +- **npm's stderr is reproduced in full** (`diagnoseExec`) — it names the registry, proxy and `.npmrc` in play, and truncating it destroys the diagnosis. +- **Nothing bypasses redaction.** Raw chains and response bodies go through `extra`, never `unsafeUnredacted`, and terminal output is scrubbed via `log.ts#redactSecrets` (the same `SCRUB_PATTERNS`) because users paste this into chats. + +`Login`/`FetchApiKey` render `describeFailure`, which upgrades **transport** errors (those with an `err.cause`) to the compact diagnosis while leaving a backend's own precise HTTP message alone — proxy-oriented reasoning about `Backend /config failed (403)` would be actively misleading. Single-line reasons stay inline (`Login failed: `); only a multi-line diagnosis breaks onto its own lines. `Login` also takes an optional `onError`, which hands failure to the parent and drops its retry prompt — `doctor` records login as one check among many and must reach its summary. + +**The re-exec handoff.** When the network group fails, `DoctorApp` offers a proxy prompt and, on submit, records `doctorOutcome.retryWithProxy` and exits — `index.tsx` spawns the retry after `waitUntilExit()` resolves. It **cannot** happen inside the component: `spawnSync` with inherited stdio while Ink still owns the TTY corrupts the terminal. `CODEV_DOCTOR_PROXY=1` on the child stops the prompt being offered twice. + +**`PREFLIGHT_CHECKS` vs `ENVIRONMENT_CHECKS`.** `SetupApp` runs the former at the head of install/config. It is strictly pure — `process.versions` and `process.env` only. The npm checks are excluded because each spawns `npm config get` (~300ms) and install runs npm for real moments later anyway; `system-ca` is excluded because the OS-store read blocks the event loop for 300ms+ on Windows, which is precisely the stall documented in the TLS section above. The pre-flight is **advisory and never blocks** — the one condition that genuinely cannot proceed (Node below the floor) is already refused at startup in `index.tsx`. + ## Diagnostic logging `~/.codev-hub` has two log homes — don't mix them up: diff --git a/README.md b/README.md index bca1848..0c97aa2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ CoDev — AI Coding Agent Hub. Install, configure, and manage multiple AI coding agents. -Requires Node.js ≥ 22.5 (Node 24+ recommended). +Requires Node.js ≥ 22.21 (Node 24+ recommended). 22.21 is the release that added +`HTTP_PROXY`/`HTTPS_PROXY` support to the Node 22 line — below it Node silently +ignores proxy settings, so sign-in cannot work behind a corporate proxy. ## Install @@ -13,11 +15,68 @@ npm install -g codev-ai Then run: ```bash +codevhub doctor # check your environment and network first codevhub install ``` After install, go to your project and type `codev`, `claude`, `codex`, or `opencode` to launch. +## Check your setup first: `codevhub doctor` + +On a corporate network — behind a proxy, a TLS-inspecting gateway, or an +internal npm mirror — run this before `codevhub install`: + +```bash +codevhub doctor +codevhub doctor --force # also force a real sign-in instead of reusing the cached session +``` + +It is read-only (it installs and configures nothing) and checks, in order: + +- **Environment** — Node version, npm, the global npm prefix (on `PATH` and + writable), the npm registry/proxy configuration, your proxy and TLS + environment variables, and whether the OS certificate store is readable. +- **Network** — the CoDev backend and the npm registry are actually reachable. +- **Account** — sign-in, gateway API key, CoDev configuration, and Supabase + (used by `codevhub upload`). +- **LLM access** — the key is valid, models are listable, and a real one-token + completion succeeds. Only the last of these proves inference is permitted; + `/key/info` and `/v1/models` both pass for a key that is then 403'd on every + completion. +- **This machine** — what is already installed, configured, and backed up. + +Failures expand in place into what happened, the most likely cause given your +proxy and TLS settings, the fix, and the raw error chain — never a bare +`fetch failed`. If the network checks fail it offers to re-run everything +through a proxy you type in (nothing is written to disk), so you see the fix +work before it prints the exact `export` / `setx` commands to make it permanent. + +Exit code is 0 when nothing failed — warnings do not fail it — and 1 otherwise. +Every result also lands in the diagnostic log, so `codevhub logs` captures a +whole run for a support ticket. + +### Working behind a proxy + +Node does **not** honor `HTTP_PROXY`/`HTTPS_PROXY` on its own — it needs +`NODE_USE_ENV_PROXY=1`, read at startup. CoDev applies that for you when it sees +a proxy configured, but it is worth setting permanently: + +```bash +export HTTP_PROXY=http://: +export HTTPS_PROXY=http://: +export NODE_USE_ENV_PROXY=1 +export NODE_USE_SYSTEM_CA=1 +``` + +Two traps `codevhub doctor` will catch for you: + +- **`NO_PROXY` covering the CoDev backend** (for example a blanket + `*.viettel.vn`) routes sign-in traffic *around* the proxy and straight into + the firewall. This is the usual cause of `Login failed`. +- **npm keeps its own proxy and TLS configuration**, separate from these + variables — `npm config set proxy`, `https-proxy`, `cafile`, `registry`. A + working `codevhub` says nothing about whether `npm i -g` will work. + ## CodeGraph integration When you run `codevhub install` or `codevhub config`, CoDev also installs [CodeGraph](https://github.com/colbymchenry/codegraph) — a local, MCP-based, pre-indexed code-knowledge-graph tool — and wires it into each agent you select (Claude Code, Codex, OpenCode, etc.). Because your codebase is pre-indexed, agents can look up symbols, references, and structure straight from the graph instead of repeatedly grepping and reading files to find their way around. That means **fewer tool calls and fewer tokens** per task — the agent spends its context on the work rather than on rediscovering the codebase. diff --git a/README.npm.md b/README.npm.md index de7c20d..c74b87a 100644 --- a/README.npm.md +++ b/README.npm.md @@ -2,7 +2,9 @@ CoDev — AI Coding Agent Hub. Install, configure, and manage multiple AI coding agents. -Requires Node.js ≥ 22.5 (Node 24+ recommended). +Requires Node.js ≥ 22.21 (Node 24+ recommended). 22.21 is the release that added +`HTTP_PROXY`/`HTTPS_PROXY` support to the Node 22 line — below it Node silently +ignores proxy settings, so sign-in cannot work behind a corporate proxy. ## Install @@ -13,11 +15,42 @@ npm install -g codev-ai Then run: ```bash +codevhub doctor # check your environment and network first codevhub install ``` After install, go to your project and type `codev`, `claude`, `codex`, or `opencode` to launch. +## Check your setup first: `codevhub doctor` + +On a corporate network — behind a proxy, a TLS-inspecting gateway, or an +internal npm mirror — run this before `codevhub install`: + +```bash +codevhub doctor +``` + +It is read-only (it installs and configures nothing) and checks, in order: + +- **Environment** — Node version, npm, the global npm prefix (on `PATH` and + writable), the npm registry/proxy configuration, your proxy and TLS + environment variables, and whether the OS certificate store is readable. +- **Network** — the CoDev backend and the npm registry are actually reachable. +- **Account** — sign-in, gateway API key, CoDev configuration, and Supabase + (used by `codevhub upload`). +- **LLM access** — the key is valid, models are listable, and a real one-token + completion succeeds. Only the last of these proves inference is permitted. +- **This machine** — what is already installed, configured, and backed up. + +When something fails it prints what happened, the most likely cause given your +proxy and TLS settings, the fix, and the raw error — never a bare +`fetch failed`. If the network checks fail it offers to re-run everything +through a proxy you type in, then prints the exact `export` / `setx` commands to +make the working settings permanent. + +Use `codevhub doctor --force` to test a real sign-in instead of reusing a cached +session. Exit code is 0 when nothing failed (warnings do not fail it), 1 otherwise. + ## CodeGraph integration When you run `codevhub install` or `codevhub config`, CoDev also installs [CodeGraph](https://github.com/colbymchenry/codegraph) — a local, MCP-based, pre-indexed code-knowledge-graph tool — and wires it into each agent you select (Claude Code, Codex, OpenCode, etc.). Because your codebase is pre-indexed, agents can look up symbols, references, and structure straight from the graph instead of repeatedly grepping and reading files to find their way around. That means **fewer tool calls and fewer tokens** per task — the agent spends its context on the work rather than on rediscovering the codebase. diff --git a/package.json b/package.json index 63acc40..b90b5a9 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "dist" ], "packageManager": "pnpm@10.26.0", + "engines": { + "node": ">=22.21.0" + }, "scripts": { "dev": "tsx src/index.tsx", "build": "tsx build.ts", diff --git a/src/DoctorApp.tsx b/src/DoctorApp.tsx new file mode 100644 index 0000000..0e1dbf3 --- /dev/null +++ b/src/DoctorApp.tsx @@ -0,0 +1,370 @@ +import { Box, Text, useApp } from "ink"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Banner } from "@/components/Banner.js"; +import { CheckList } from "@/components/CheckList.js"; +import { Frame } from "@/components/Frame.js"; +import { Login, loginTitle } from "@/components/Login.js"; +import { ProxyPrompt, proxyPromptTitle } from "@/components/ProxyPrompt.js"; +import { Step } from "@/components/Step.js"; +import { type AuthData, logout } from "@/lib/auth.js"; +import { SSO_URL } from "@/lib/const.js"; +import { + ACCOUNT_CHECKS, + alreadyRetriedWithProxy, + buildNextSteps, + type Check, + type CheckGroup, + type CheckOutcome, + type DoctorContext, + diagnoseError, + doctorOutcome, + ENVIRONMENT_CHECKS, + hasFailure, + LLM_CHECKS, + NETWORK_CHECKS, + runChecks, + STATE_CHECKS, +} from "@/lib/doctor.js"; +import { logDebug } from "@/lib/log.js"; +import { hasProxyConfigured, readProxyEnv } from "@/lib/proxy.js"; + +type Phase = + | "preparing" + | "environment" + | "network" + | "proxy-prompt" + | "login" + | "account" + | "llm" + | "state" + | "done"; + +const GROUP_TITLES: Record = { + environment: "Environment", + network: "Network", + account: "Account & credentials", + llm: "LLM access", + state: "This machine", +}; + +const GROUP_CHECKS: Record = { + environment: ENVIRONMENT_CHECKS, + network: NETWORK_CHECKS, + account: ACCOUNT_CHECKS, + llm: LLM_CHECKS, + state: STATE_CHECKS, +}; + +// Phases at or after which a group's Step should stay mounted (read-only +// history), mirroring SetupApp's POST_* arrays. +const AFTER: Record = { + environment: [ + "environment", + "network", + "proxy-prompt", + "login", + "account", + "llm", + "state", + "done", + ], + network: [ + "network", + "proxy-prompt", + "login", + "account", + "llm", + "state", + "done", + ], + account: ["account", "llm", "state", "done"], + llm: ["llm", "state", "done"], + state: ["state", "done"], +}; + +const EMPTY: Record = { + environment: [], + network: [], + account: [], + llm: [], + state: [], +}; + +interface DoctorAppProps { + /** `--force`: wipe the cached SSO session so sign-in is genuinely exercised. */ + force?: boolean; +} + +export function DoctorApp({ force = false }: DoctorAppProps) { + const { exit } = useApp(); + const [phase, setPhase] = useState( + force ? "preparing" : "environment", + ); + const [outcomes, setOutcomes] = + useState>(EMPTY); + // Login is rendered by , but its pass/fail still belongs in the + // summary and the exit code, so it gets an outcome of its own. + const [loginOutcome, setLoginOutcome] = useState(null); + const [proxyUrl, setProxyUrl] = useState(null); + const didPrepRef = useRef(false); + // Checks mutate the context as they resolve (token → key → gateway URL → + // models), so it must be a ref: a state read would see a stale snapshot + // within the same run. + const ctxRef = useRef({}); + const startedRef = useRef>(new Set()); + + useEffect(() => { + logDebug(`doctor step: ${phase}`, { + action: "step.transition", + extra: { step: phase, command: "doctor" }, + }); + }, [phase]); + + // --force wipes the cached session first, so `sso-login` measures a real + // round trip rather than reporting the cache. Same approach as LoginApp. + useEffect(() => { + if (!force || didPrepRef.current) return; + didPrepRef.current = true; + logout().finally(() => setPhase("environment")); + }, [force]); + + const runGroup = useCallback((group: CheckGroup, next: Phase) => { + if (startedRef.current.has(group)) return; + startedRef.current.add(group); + runChecks(GROUP_CHECKS[group], ctxRef.current, (outcome) => { + setOutcomes((prev) => ({ + ...prev, + [group]: [...prev[group], outcome], + })); + }).then((results) => { + // The network group is the proxy gate: a hard failure there is the + // signal that this machine may need a proxy we don't know about. Offer + // the prompt once — never when we're already the retry child, and never + // when a proxy is configured AND active (it's set up; something else is + // wrong, and asking again would just be noise). + if (group === "network" && hasFailure(results)) { + const env = readProxyEnv(); + const proxyAlreadyWorking = hasProxyConfigured(env) && env.useEnvProxy; + if (!alreadyRetriedWithProxy() && !proxyAlreadyWorking) { + setPhase("proxy-prompt"); + return; + } + } + setPhase(next); + }); + }, []); + + useEffect(() => { + if (phase === "environment") runGroup("environment", "network"); + else if (phase === "network") runGroup("network", "login"); + else if (phase === "account") runGroup("account", "llm"); + else if (phase === "llm") runGroup("llm", "state"); + else if (phase === "state") runGroup("state", "done"); + }, [phase, runGroup]); + + const handleProxySubmit = useCallback( + (url: string | null) => { + if (!url) { + // Skipped — keep going so the user still gets the account/LLM + // results (they may be on a network that needs no proxy) and the + // full setup instructions at the end. + setPhase("login"); + return; + } + setProxyUrl(url); + // The re-exec cannot happen here: spawnSync with inherited stdio while + // Ink owns the TTY corrupts the terminal. Record the intent and let + // index.tsx act on it once waitUntilExit() has resolved. + doctorOutcome.retryWithProxy = { http: url, https: url }; + setTimeout(() => exit(), 300); + }, + [exit], + ); + + const handleLoginDone = useCallback((auth: AuthData) => { + ctxRef.current.accessToken = auth.access_token; + setLoginOutcome({ + key: "sso-login", + label: "Sign in to SSO", + group: "account", + status: "pass", + detail: `Signed in as ${auth.user.email}.`, + }); + setPhase("account"); + }, []); + + const handleLoginError = useCallback((err: unknown) => { + const diagnosis = diagnoseError(err, { url: SSO_URL, method: "GET" }); + setLoginOutcome({ + key: "sso-login", + label: "Sign in to SSO", + group: "account", + status: "fail", + detail: diagnosis.what, + fix: diagnosis.fix, + diagnosis, + }); + // Keep going. The account/LLM checks report themselves as skipped without + // a token, which tells the reader exactly how far the flow got. + setPhase("account"); + }, []); + + // Terminal phase: compute the exit code, then hold the frame briefly so Ink + // flushes the summary before unmounting. + useEffect(() => { + if (phase !== "done") return; + const all = [ + ...outcomes.environment, + ...outcomes.network, + ...(loginOutcome ? [loginOutcome] : []), + ...outcomes.account, + ...outcomes.llm, + ]; + doctorOutcome.exitCode = hasFailure(all) ? 1 : 0; + const timer = setTimeout(() => exit(), 1000); + return () => clearTimeout(timer); + }, [phase, outcomes, loginOutcome, exit]); + + const groupProps = (group: CheckGroup) => { + const done = outcomes[group]; + const pending = GROUP_CHECKS[group] + .filter((c) => !done.some((o) => o.key === c.key)) + .map((c) => c.label); + const active = phase === group; + return { + outcomes: done, + // The head of the pending list is what's executing right now. + running: active ? (pending[0] ?? null) : null, + pending: active ? pending.slice(1) : [], + }; + }; + + const allOutcomes = [ + ...outcomes.environment, + ...outcomes.network, + ...(loginOutcome ? [loginOutcome] : []), + ...outcomes.account, + ...outcomes.llm, + ...outcomes.state, + ]; + const nextSteps = + phase === "done" + ? buildNextSteps( + allOutcomes, + proxyUrl ? { http: proxyUrl, https: proxyUrl } : undefined, + ) + : []; + + return ( + + + + {phase === "preparing" && ( + Signing out previous session}> + Revoking tokens... + + )} + + {(["environment", "network"] as const).map( + (group) => + AFTER[group].includes(phase) && ( + {GROUP_TITLES[group]}} + > + + + ), + )} + + {phase === "proxy-prompt" && ( + + + + )} + + {phase !== "preparing" && + phase !== "environment" && + phase !== "network" && + phase !== "proxy-prompt" && ( + + + {loginOutcome?.status === "fail" && ( + + )} + + )} + + {(["account", "llm", "state"] as const).map( + (group) => + AFTER[group].includes(phase) && ( + {GROUP_TITLES[group]}} + > + + + ), + )} + + {phase === "done" && ( + Result}> + + + )} + + + ); +} + +function Summary({ + outcomes, + nextSteps, +}: { + outcomes: CheckOutcome[]; + nextSteps: string[]; +}) { + const failed = outcomes.filter((o) => o.status === "fail").length; + const warned = outcomes.filter((o) => o.status === "warn").length; + + return ( + + {failed === 0 && warned === 0 && ( + + {"✓ Everything checks out. You're ready to run `codevhub install`."} + + )} + {failed === 0 && warned > 0 && ( + + {`▲ ${warned} warning(s). \`codevhub install\` should work, but read the notes below first.`} + + )} + {failed > 0 && ( + // Name the warnings too: the Next steps list below numbers failures + // and warnings together, so a bare "5 failed" above 7 numbered + // items reads as a contradiction. + + {`✗ ${failed} check(s) failed${ + warned > 0 ? `, ${warned} warning(s)` : "" + }. Fix these before running \`codevhub install\`.`} + + )} + {nextSteps.length > 0 && ( + + {"Next steps"} + {nextSteps.map((line, i) => ( + + {line} + + ))} + + )} + + ); +} diff --git a/src/SetupApp.tsx b/src/SetupApp.tsx index 17a51d0..c2498ed 100644 --- a/src/SetupApp.tsx +++ b/src/SetupApp.tsx @@ -1,12 +1,13 @@ import { Box, Text, useApp } from "ink"; import Spinner from "ink-spinner"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { AuthMethodChoice } from "@/components/AuthMethod.js"; import { AuthMethod, configurationMethodTitle, } from "@/components/AuthMethod.js"; import { Banner } from "@/components/Banner.js"; +import { CheckList } from "@/components/CheckList.js"; import { Configure, configureTitle } from "@/components/Configure.js"; import { Confirm, confirmTitle } from "@/components/Confirm.js"; import { @@ -56,12 +57,20 @@ import { type Tool, } from "@/lib/configure.js"; import { FALLBACK_MODEL } from "@/lib/const.js"; +import { + type CheckOutcome, + PREFLIGHT_CHECKS, + runChecks, +} from "@/lib/doctor.js"; import { logApiKeyConfigured, logDebug, logError, logWarn } from "@/lib/log.js"; import { providerFromName } from "@/lib/provider.js"; import { installShims, toolToShimAgent } from "@/lib/shims.js"; import { disableClaudeCodeLoginPrompt } from "@/lib/vscode-settings.js"; type Phase = + // Pure environment checks (Node version, proxy/TLS env), before the user is + // asked for anything. Advisory only — it always advances to "select". + | "preflight" | "select" | "editor-select" | "confirm" @@ -158,7 +167,7 @@ interface SetupAppProps { export function SetupApp({ mode }: SetupAppProps) { const { exit } = useApp(); - const [step, setStep] = useState("select"); + const [step, setStep] = useState("preflight"); const [tools, setTools] = useState([]); // Survivors of the install step — populated from TaskList's succeededKeys. // Drives shim install + the Configure step, so failed tools are quietly @@ -201,6 +210,23 @@ export function SetupApp({ mode }: SetupAppProps) { // (and the row stays unmounted) on the success path, so refresh remains // invisible when it works. const [refreshWarning, setRefreshWarning] = useState(null); + // Pure environment checks, shared verbatim with `codevhub doctor`. + const [preflight, setPreflight] = useState([]); + const preflightStartedRef = useRef(false); + + // Surface a misconfigured proxy BEFORE the user invests a few minutes in the + // wizard — on a corporate network that misconfiguration is what kills the + // run at sign-in, and the message is the same either way. Advisory by + // design: it never blocks, because the one condition that genuinely cannot + // proceed (Node below the floor) is already refused at startup in + // index.tsx, and a proxy warning must not stop a user who has no proxy. + useEffect(() => { + if (step !== "preflight" || preflightStartedRef.current) return; + preflightStartedRef.current = true; + runChecks(PREFLIGHT_CHECKS, {}, (outcome) => { + setPreflight((prev) => [...prev, outcome]); + }).then(() => setStep("select")); + }, [step]); // Diagnostic trail of the wizard's progress — one document per phase // transition, so a stuck or aborted run shows exactly where it stopped. @@ -591,244 +617,276 @@ export function SetupApp({ mode }: SetupAppProps) { [creds, runFinalizeSideEffects], ); + // Nothing downstream of the pre-flight should mount while it runs — several + // of the Steps below key off `step !== "select"` and would otherwise render + // their read-only history before the user has chosen anything. + const inPreflight = step === "preflight"; + return ( - - - - {(claudeCodeExtSelected || continueSelected) && step !== "select" && ( + {preflight.length > 0 && ( Checking your environment} > - - - )} - {step !== "select" && step !== "editor-select" && ( - - - - )} - {step !== "select" && - step !== "editor-select" && - step !== "confirm" && ( - - - - )} - {(mode === "install" || - (mode === "config" && codegraphEligible(tools))) && - POST_LOGIN.includes(step) && ( - - {mode === "install" - ? "Installing packages" - : "Installing CodeGraph"} - - } - > - - - )} - {POST_REFRESH.includes(step) && refreshWarning && ( - Refresh CoDev config}> - - - {` ${refreshWarning}`} - - - )} - {POST_REFRESH.includes(step) && savedCreds && ( - Checking saved API key} - > - {step === "validating-existing" ? ( - - - - - Verifying with gateway... - - ) : ( + {preflight.some((o) => o.status !== "pass") && ( - {existingValid - ? "Saved API key is valid." - : (existingMessage ?? "")} + { + "Run `codevhub doctor` for the full check — npm, network, sign-in and LLM access — plus setup instructions." + } )} )} - {POST_VALIDATE.includes(step) && ( - - - - )} - {POST_KEY_CHOICE.includes(step) && authMethod === "new" && auth && ( - - - - )} - {POST_KEY_CHOICE.includes(step) && - (authMethod === "manual" || - (authMethod === "new" && step === "manual-creds")) && ( - - - - )} - {POST_KEY_CHOICE.includes(step) && - authMethod !== "skip" && - modelWarning && ( - Model list}> - - - {` ${modelWarning}`} - - - )} - {POST_KEY_CHOICE.includes(step) && - authMethod !== "skip" && - creds && - step !== "fetching-key" && - step !== "manual-creds" && ( + {!inPreflight && ( + <> - - )} - {POST_VERIFY_GATEWAY.includes(step) && creds && ( - Verifying gateway access} - > - {step === "verifying-gateway" ? ( - - - - - {` Sending a test request to ${chosenModel ?? "the model"}…`} - - ) : smokeWarning ? ( - + {(claudeCodeExtSelected || continueSelected) && + step !== "select" && ( + + + + )} + {step !== "select" && step !== "editor-select" && ( + + + + )} + {step !== "select" && + step !== "editor-select" && + step !== "confirm" && ( + + + + )} + {(mode === "install" || + (mode === "config" && codegraphEligible(tools))) && + POST_LOGIN.includes(step) && ( + + {mode === "install" + ? "Installing packages" + : "Installing CodeGraph"} + + } + > + + + )} + {POST_REFRESH.includes(step) && refreshWarning && ( + Refresh CoDev config}> - {` ${smokeWarning}`} + {` ${refreshWarning}`} - - { - "Config was still written, but your agents will hit this same error — fix gateway access (model entitlement, budget, or region/IP), then relaunch." - } - - - ) : ( - {"Gateway accepted a test request."} + )} - - )} - {POST_MODEL_CHOICE.includes(step) && - (creds || authMethod === "skip") && - (authMethod === "skip" ? ( - // Skip configuration runs Configure for its backup - // side-effects only — rendered bare (no Step, no title) so - // nothing appears in the TUI. Configure itself emits no rows - // on the creds === null path. - - ) : ( - - - - ))} - {(step === "finalizing" || step === "done") && - codegraphEligible(installedTools) && - codegraphResult?.status !== "skipped" && ( - Set up CodeGraph} - > - {codegraphResult === null ? ( - - - + {POST_REFRESH.includes(step) && savedCreds && ( + Checking saved API key} + > + {step === "validating-existing" ? ( + + + + + Verifying with gateway... + + ) : ( + + {existingValid + ? "Saved API key is valid." + : (existingMessage ?? "")} - Setting up CodeGraph… - - ) : codegraphResult.status === "warning" ? ( - - - - {` ${codegraphResult.message ?? "CodeGraph setup did not complete."}`} - - + )} + + )} + {POST_VALIDATE.includes(step) && ( + + + + )} + {POST_KEY_CHOICE.includes(step) && authMethod === "new" && auth && ( + + + + )} + {POST_KEY_CHOICE.includes(step) && + (authMethod === "manual" || + (authMethod === "new" && step === "manual-creds")) && ( + + + + )} + {POST_KEY_CHOICE.includes(step) && + authMethod !== "skip" && + modelWarning && ( + Model list}> + + + {` ${modelWarning}`} + + + )} + {POST_KEY_CHOICE.includes(step) && + authMethod !== "skip" && + creds && + step !== "fetching-key" && + step !== "manual-creds" && ( + + + + )} + {POST_VERIFY_GATEWAY.includes(step) && creds && ( + Verifying gateway access} + > + {step === "verifying-gateway" ? ( + + + + + {` Sending a test request to ${chosenModel ?? "the model"}…`} + + ) : smokeWarning ? ( + + + + {` ${smokeWarning}`} + + + { + "Config was still written, but your agents will hit this same error — fix gateway access (model entitlement, budget, or region/IP), then relaunch." + } + + + ) : ( + {"Gateway accepted a test request."} + )} + + )} + {POST_MODEL_CHOICE.includes(step) && + (creds || authMethod === "skip") && + (authMethod === "skip" ? ( + // Skip configuration runs Configure for its backup + // side-effects only — rendered bare (no Step, no title) so + // nothing appears in the TUI. Configure itself emits no rows + // on the creds === null path. + ) : ( - - {`Wired CodeGraph into ${formatCodegraphTargets(codegraphResult.targets)}.`} - + + + + ))} + {(step === "finalizing" || step === "done") && + codegraphEligible(installedTools) && + codegraphResult?.status !== "skipped" && ( + Set up CodeGraph} + > + {codegraphResult === null ? ( + + + + + Setting up CodeGraph… + + ) : codegraphResult.status === "warning" ? ( + + + + {` ${codegraphResult.message ?? "CodeGraph setup did not complete."}`} + + + ) : ( + + {`Wired CodeGraph into ${formatCodegraphTargets(codegraphResult.targets)}.`} + + )} + )} - - )} - {step === "done" && ( - + {step === "done" && ( + + )} + )} diff --git a/src/components/CheckList.tsx b/src/components/CheckList.tsx new file mode 100644 index 0000000..11f4416 --- /dev/null +++ b/src/components/CheckList.tsx @@ -0,0 +1,146 @@ +import { Box, Text } from "ink"; +import Spinner from "ink-spinner"; +import type { CheckOutcome, CheckStatus, Diagnosis } from "@/lib/doctor.js"; + +interface CheckListProps { + /** Labels of checks that have not produced an outcome yet, in order. */ + pending: string[]; + outcomes: CheckOutcome[]; + /** The label currently executing, rendered with a spinner. */ + running?: string | null; + /** + * When true, passing rows collapse to a single summary line. Used by the + * install pre-flight, where a clean environment should cost one line — + * `codevhub doctor` itself always shows every row. + */ + collapsePasses?: boolean; +} + +/** + * Doctor's row renderer. + * + * Deliberately a sibling of TaskList rather than an extension of it: TaskList's + * rowText is phrased entirely around installing things ("Installed X", "Failed + * to install X"), and a check row needs a detail line plus, on failure, a full + * multi-part diagnosis. Sharing the icon vocabulary (✓ ▲ ✗ ○) is the part that + * matters for consistency, and that is duplicated intentionally — the ▲/⚠ width + * note in TaskList applies here too. + */ +export function CheckList({ + pending, + outcomes, + running, + collapsePasses = false, +}: CheckListProps) { + const passes = outcomes.filter((o) => o.status === "pass"); + const shown = collapsePasses + ? outcomes.filter((o) => o.status !== "pass") + : outcomes; + + return ( + + {collapsePasses && passes.length > 0 && ( + + + {` ${passes.length} environment checks passed`} + + )} + {shown.map((outcome) => ( + + ))} + {running && ( + + + + + {` ${running}...`} + + )} + {pending.map((label) => ( + + + {` ${label}`} + + ))} + + ); +} + +function StatusIcon({ status }: { status: CheckStatus }) { + if (status === "pass") return ; + // `▲` (U+25B2) renders single-cell in monospace fonts; `⚠` (U+26A0) sits in + // the emoji bucket and renders ~2 cells wide, breaking row alignment. + if (status === "warn") return ; + if (status === "fail") return ; + return ; +} + +function CheckRow({ outcome }: { outcome: CheckOutcome }) { + const color = + outcome.status === "fail" + ? "red" + : outcome.status === "warn" + ? "yellow" + : undefined; + return ( + + + + {` ${outcome.label}`} + + + {outcome.detail} + + {/* Failures expand in place. There is no --verbose flag on purpose: + explaining the failure IS the command, and a user who has to + re-run with a flag to find out why has already been failed once. */} + {outcome.status === "fail" && outcome.diagnosis && ( + + )} + {/* Warnings carry their fix inline; they have no diagnosis block. */} + {outcome.status === "warn" && outcome.fix && ( + + {outcome.fix.split("\n").map((line, i) => ( + + {line} + + ))} + + )} + + ); +} + +const LABEL_WIDTH = 15; + +function Field({ name, children }: { name: string; children: string[] }) { + if (children.length === 0) return null; + return ( + + {children.map((line, i) => ( + + + + {i === 0 ? name : ""} + + + + {line} + + + ))} + + ); +} + +function DiagnosisBlock({ diagnosis }: { diagnosis: Diagnosis }) { + return ( + + {[diagnosis.what]} + {[diagnosis.cause]} + {diagnosis.fix.split("\n")} + {diagnosis.context} + {diagnosis.raw} + + ); +} diff --git a/src/components/FetchApiKey.tsx b/src/components/FetchApiKey.tsx index 02d9f3d..2bf8d14 100644 --- a/src/components/FetchApiKey.tsx +++ b/src/components/FetchApiKey.tsx @@ -3,6 +3,8 @@ import Spinner from "ink-spinner"; import { useEffect, useState } from "react"; import type { AuthData } from "@/lib/auth.js"; import { fetchApiKey } from "@/lib/backend.js"; +import { BACKEND_URL } from "@/lib/const.js"; +import { describeFailure } from "@/lib/doctor.js"; interface FetchApiKeyProps { auth: AuthData; @@ -40,7 +42,14 @@ export function FetchApiKey({ auth, onDone, onFallback }: FetchApiKeyProps) { }) .catch((err: Error) => { setPending(false); - setError(err.message); + // A transport failure here (proxy/TLS/DNS) gets the full diagnosis; + // a backend HTTP error keeps its own already-precise message. + setError( + describeFailure(err, { + url: `${BACKEND_URL}/auth/exchange`, + method: "POST", + }), + ); }); }, [auth.access_token, onDone, attempt]); @@ -75,7 +84,17 @@ export function FetchApiKey({ auth, onDone, onFallback }: FetchApiKeyProps) { )} {error && ( <> - {`Failed to fetch API key: ${error}`} + {/* One-line reasons stay inline; a multi-line transport + diagnosis keeps its structure on following lines. */} + {`Failed to fetch API key: ${error.split("\n")[0] ?? ""}`} + {error + .split("\n") + .slice(1) + .map((line, i) => ( + + {line} + + ))} {"Press Enter to retry, Ctrl-C to quit"} )} diff --git a/src/components/Login.tsx b/src/components/Login.tsx index e34e4ec..28177ca 100644 --- a/src/components/Login.tsx +++ b/src/components/Login.tsx @@ -4,7 +4,8 @@ import { useCallback, useEffect, useState } from "react"; import { PasteBackPrompt, usePasteBack } from "@/components/PasteBack.js"; import { type AuthData, login } from "@/lib/auth.js"; import { clipboard } from "@/lib/clipboard.js"; -import { describeNetworkError } from "@/lib/tls.js"; +import { SSO_URL } from "@/lib/const.js"; +import { describeFailure } from "@/lib/doctor.js"; interface LoginProps { onDone: (auth: AuthData) => void; @@ -14,9 +15,15 @@ interface LoginProps { // only surfaces for headless/remote machines or a browser that didn't open. // Overridable so tests can reveal it immediately. fallbackDelayMs?: number; + // When provided, the parent owns the failure: this component reports the + // error upward and drops its own "Press Enter to retry" affordance. Used by + // `codevhub doctor`, which records login as one check among many and must + // keep going to the summary rather than parking on a retry prompt. Absent + // (install / config / login), the retry behaviour is unchanged. + onError?: (err: unknown) => void; } -export function Login({ onDone, fallbackDelayMs = 3000 }: LoginProps) { +export function Login({ onDone, fallbackDelayMs = 3000, onError }: LoginProps) { const [logs, setLogs] = useState([]); const [error, setError] = useState(null); const [attempt, setAttempt] = useState(0); @@ -72,11 +79,15 @@ export function Login({ onDone, fallbackDelayMs = 3000 }: LoginProps) { onDone(auth); }) .catch((err: Error) => { - // Unwraps Node's bare `fetch failed` to the real reason (DNS, TLS, - // proxy interception) and appends a remedy for certificate failures. - setError(describeNetworkError(err)); + // Full diagnosis, not Node's bare `fetch failed`: names the real + // failure (DNS / refused / timeout / TLS interception), says what + // most likely caused it on this machine given the proxy state, and + // gives the fix. This is where users on a corporate network most + // often meet a network error, so it gets the good message. + setError(describeFailure(err, { url: SSO_URL })); + onError?.(err); }); - }, [addLog, onDone, attempt]); + }, [addLog, onDone, onError, attempt]); // Reveal the manual fallback a few seconds after the URL is ready, so the // happy path stays a clean one-line spinner. @@ -102,14 +113,28 @@ export function Login({ onDone, fallbackDelayMs = 3000 }: LoginProps) { useInput((_input, key) => { // A fatal failure takes over the screen: Enter restarts the attempt. - if (error && key.return) setAttempt((n) => n + 1); + // When the parent handles errors (doctor), it decides what happens next + // and this key would fight with its own flow. + if (!onError && error && key.return) setAttempt((n) => n + 1); }); if (error) { + // A plain one-line reason stays inline ("Login failed: "); a + // multi-line diagnosis (what happened / likely cause / fix) breaks onto + // its own lines so the structure survives. + const lines = error.split("\n"); + const [first = "", ...rest] = lines; return ( - {`Login failed: ${error}`} - {"Press Enter to retry, Ctrl-C to quit"} + {`Login failed: ${first}`} + {rest.map((line, i) => ( + + {line} + + ))} + {!onError && ( + {"Press Enter to retry, Ctrl-C to quit"} + )} ); } diff --git a/src/components/ProxyPrompt.tsx b/src/components/ProxyPrompt.tsx new file mode 100644 index 0000000..980d5aa --- /dev/null +++ b/src/components/ProxyPrompt.tsx @@ -0,0 +1,93 @@ +import { Box, Text, useInput } from "ink"; +import { useState } from "react"; +import { normalizeProxyInput } from "@/lib/doctor.js"; + +interface ProxyPromptProps { + /** Called with a normalized proxy URL, or null when the user skips. */ + onSubmit: (proxyUrl: string | null) => void; + readOnly?: boolean; +} + +/** + * Single `host:port` field, offered when the network checks fail and no working + * proxy is configured. + * + * Hand-rolled on `useInput` like ManualCredentials — the repo deliberately + * carries no text-input dependency, and one optional field does not justify + * adding one. + */ +export function ProxyPrompt({ onSubmit, readOnly = false }: ProxyPromptProps) { + const [value, setValue] = useState(""); + const [error, setError] = useState(null); + const [submitted, setSubmitted] = useState(false); + + useInput( + (input, key) => { + if (submitted) return; + + if (key.return) { + const trimmed = value.trim(); + // Blank means "skip" — the failure stays recorded and the summary + // still prints the setup instructions. + if (!trimmed) { + setSubmitted(true); + onSubmit(null); + return; + } + const url = normalizeProxyInput(trimmed); + if (!url) { + setError( + "That doesn't look like a proxy address. Use host:port, e.g. 10.0.0.1:8080", + ); + return; + } + setError(null); + setSubmitted(true); + onSubmit(url); + return; + } + + if (key.backspace || key.delete) { + setValue((prev) => prev.slice(0, -1)); + return; + } + + // Ignore control keys so escape sequences never leak into the field. + if (key.ctrl || key.meta || key.escape) return; + if (!input) return; + setValue((prev) => prev + input); + }, + { isActive: !readOnly && !submitted }, + ); + + if (submitted) { + return ( + + {value.trim() ? `Retrying via ${value.trim()}…` : "Skipped."} + + ); + } + + return ( + + + { + "The network checks failed. If this machine reaches the internet through a proxy, enter it here and CoDev will re-run the checks with it applied." + } + + + {"Nothing is written to disk — this applies to this run only."} + + + {"Proxy (host:port), or Enter to skip: "} + {value} + + + {error && {error}} + + ); +} + +export function proxyPromptTitle() { + return {"Configure a proxy"}; +} diff --git a/src/index.tsx b/src/index.tsx index 924a9c5..91117f7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,15 +3,24 @@ import { join } from "node:path"; import { styleText } from "node:util"; import { render } from "ink"; import { ConfigApp } from "@/ConfigApp.js"; +import { DoctorApp } from "@/DoctorApp.js"; import { InstallApp } from "@/InstallApp.js"; import { LoginApp } from "@/LoginApp.js"; import { clearSkillhubCookie, logout } from "@/lib/auth.js"; import { runClearLogs } from "@/lib/clear-logs.js"; import { forwardToCodegraph } from "@/lib/codegraph.js"; +import { + MIN_NODE_STRING, + NODE_DOWNLOAD_URL, + nodeVersionMeets, + RECOMMENDED_NODE, +} from "@/lib/const.js"; +import { DOCTOR_PROXY_ENV, doctorOutcome } from "@/lib/doctor.js"; import { printHelp, printVersion } from "@/lib/help.js"; -import { initLogging } from "@/lib/log.js"; +import { currentTraceId, initLogging } from "@/lib/log.js"; import { runLogs } from "@/lib/logs.js"; -import { ensureNodeSqliteOrReexec } from "@/lib/reexec.js"; +import { applyEnvProxy, backendHost, stripNoProxyFor } from "@/lib/proxy.js"; +import { ensureNodeSqliteOrReexec, spawner } from "@/lib/reexec.js"; import { ensureFreshGatewayKey } from "@/lib/refresh.js"; import { RESTORE_AGENTS, @@ -41,16 +50,18 @@ import { SkillPushApp } from "@/SkillPushApp.js"; import { UpdateApp } from "@/UpdateApp.js"; import { UploadApp } from "@/UploadApp.js"; -// `node:sqlite` (used by the OpenCode provider) was added in Node 22.5 and -// stabilized in Node 23.5. Earlier 22.x patches don't expose the module even -// with --experimental-sqlite. -const MIN_NODE_VERSION = "22.5.0"; -const [nodeMajor = 0, nodeMinor = 0] = process.versions.node - .split(".") - .map((n) => Number.parseInt(n, 10) || 0); -if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 5)) { +// The floor is 22.21.0 — the release that backported HTTP_PROXY/HTTPS_PROXY +// support to the Node 22 line. Below it Node silently ignores proxy environment +// variables, so on a corporate network every command fails at sign-in with no +// way to fix it. (`node:sqlite`, the previous reason for a 22.5 floor, is +// comfortably below this and still handled by the --experimental-sqlite +// re-exec in lib/reexec.ts.) +if (!nodeVersionMeets(process.versions.node)) { console.error( - `CoDev requires Node.js >= ${MIN_NODE_VERSION}. Current version: ${process.versions.node}.`, + `CoDev requires Node.js >= ${MIN_NODE_STRING} (Node ${RECOMMENDED_NODE} recommended). ` + + `Current version: ${process.version}.\n` + + `Below ${MIN_NODE_STRING}, Node ignores HTTP_PROXY/HTTPS_PROXY entirely, so sign-in ` + + `cannot work behind a corporate proxy.\nDownload: ${NODE_DOWNLOAD_URL}`, ); process.exit(1); } @@ -82,11 +93,65 @@ function flagValue(argv: string[], name: string): string | undefined { return undefined; } +// Re-run `codevhub doctor` with the proxy the user just typed, so they see the +// fix actually work before being told to make it permanent. Nothing is written +// to disk; the settings live only in the child's environment. +function rerunDoctorWithProxy(proxy: { http: string; https: string }): number { + const selfPath = process.argv[1]; + if (!selfPath) { + console.error("Could not determine the CLI path to re-run."); + return 1; + } + const traceId = currentTraceId(); + const env: NodeJS.ProcessEnv = { + ...process.env, + HTTP_PROXY: proxy.http, + HTTPS_PROXY: proxy.https, + NODE_USE_ENV_PROXY: "1", + // Intercepting proxies re-sign TLS with a corporate root, so reading the + // OS trust store is part of "try it with the proxy", not a separate step. + NODE_USE_SYSTEM_CA: "1", + // Offer the prompt only once — if the retry still fails, the summary must + // be allowed to print rather than asking again. + [DOCTOR_PROXY_ENV]: "1", + ...(traceId ? { CODEV_TRACE_PARENT: traceId } : {}), + }; + // A NO_PROXY entry covering our own backend would send that traffic direct + // and defeat the proxy we're testing — the documented cause of "Login + // failed". Drop it for the child only; the user's environment is untouched. + const host = backendHost(); + if (env.NO_PROXY) env.NO_PROXY = stripNoProxyFor(env.NO_PROXY, host); + if (env.no_proxy) env.no_proxy = stripNoProxyFor(env.no_proxy, host); + + const result = spawner.spawnSync( + process.execPath, + [...process.execArgv, selfPath, "doctor", ...args], + { stdio: "inherit", env }, + ); + return result.status ?? 1; +} + // Diagnostic logging (~/.codev-hub/logs/codev-YYYYMMDD.ndjson, ECS NDJSON) starts // before dispatch so every command logs its start/end and crashes. File-only — // never stdout/stderr, which the Ink apps own. initLogging(command ?? "help", args); +// Node does not honor HTTP_PROXY/HTTPS_PROXY unless NODE_USE_ENV_PROXY is set, +// and it reads that only at startup. Users who follow the install guide and +// export just the proxy variables therefore get no proxy at all — silently. +// Enable it here, before dispatch, so every command benefits rather than just +// the one being debugged. Best-effort: a failure never blocks the command, +// because `codevhub doctor` must still be able to run and explain why. +{ + const proxyResult = applyEnvProxy(); + if (proxyResult.action === "reexec") process.exit(proxyResult.exitCode ?? 1); + if (proxyResult.noProxyWarning) { + // Not fixable on the user's behalf — rewriting their NO_PROXY would be + // overreach — but it silently defeats the proxy, so say so once. + process.stderr.write(`Warning: ${proxyResult.noProxyWarning}\n`); + } +} + // Rewrite shims left behind by pre-0.4 hub versions (their bodies re-exec the // old `codev` hub command, which is now the agent). Best-effort: a filesystem // hiccup here must never block the actual command. @@ -143,6 +208,26 @@ switch (command) { } break; } + // Pre-flight for everything `install` depends on: Node version, npm, proxy + // and TLS environment, network reachability, sign-in, API key, and a real + // LLM completion. Read-only — it never installs or configures anything. + case "doctor": { + const { waitUntilExit } = render( + , + ); + try { + await waitUntilExit(); + } catch { + process.exit(1); + } + // The app cannot re-exec itself: spawnSync with inherited stdio while Ink + // still owns the TTY corrupts the terminal. It records the intent instead + // and we act on it here, now that Ink has unmounted. + const retry = doctorOutcome.retryWithProxy; + if (retry) process.exit(rerunDoctorWithProxy(retry)); + process.exit(doctorOutcome.exitCode); + break; + } case "update": { const { waitUntilExit } = render(); try { diff --git a/src/lib/const.ts b/src/lib/const.ts index eba7257..c2ba6dc 100644 --- a/src/lib/const.ts +++ b/src/lib/const.ts @@ -30,6 +30,35 @@ export const GATEWAY_MAX_OUTPUT_TOKENS = 65536; export const VERSION: string = pkg.version; +// Node 22.21.0 (2025-10-20) is where HTTP_PROXY/HTTPS_PROXY support was +// backported to the 22 LTS line (nodejs/node#57872). Below it Node's `fetch` +// silently ignores proxy environment variables, so sign-in can never work +// behind a corporate proxy no matter how the user configures their shell — +// which is why the floor is this oddly specific patch and not a rounder number. +// It supersedes the older 22.5 floor, which only existed for `node:sqlite`. +// +// Lives here rather than in lib/doctor.ts so index.tsx can gate on it without +// pulling in doctor's dependency graph before the version check has run. +export const MIN_NODE = { major: 22, minor: 21, patch: 0 } as const; +export const MIN_NODE_STRING = "22.21.0"; +export const RECOMMENDED_NODE = "24 (LTS)"; +export const NODE_DOWNLOAD_URL = "https://nodejs.org/en/download"; + +export function parseNodeVersion(version: string): [number, number, number] { + const [major = 0, minor = 0, patch = 0] = version + .replace(/^v/, "") + .split(".") + .map((n) => Number.parseInt(n, 10) || 0); + return [major, minor, patch]; +} + +export function nodeVersionMeets(version: string): boolean { + const [major, minor, patch] = parseNodeVersion(version); + if (major !== MIN_NODE.major) return major > MIN_NODE.major; + if (minor !== MIN_NODE.minor) return minor > MIN_NODE.minor; + return patch >= MIN_NODE.patch; +} + export const HELP_HINT = "Run `codevhub --help` to see all commands."; export const HAPPY_CODING = "Happy coding! 🎉"; diff --git a/src/lib/doctor.ts b/src/lib/doctor.ts new file mode 100644 index 0000000..499dd44 --- /dev/null +++ b/src/lib/doctor.ts @@ -0,0 +1,1572 @@ +import { accessSync, existsSync, constants as fsConstants } from "node:fs"; +import { delimiter, join } from "node:path"; +import { loadApiKey } from "@/lib/auth.js"; +import { + fetchApiKey, + fetchCodevConfig, + fetchModels, + fetchSupabaseSession, + smokeTestModel, + validateApiKey, +} from "@/lib/backend.js"; +import { + detectConfiguredTools, + getBackupStatus, + type Tool, +} from "@/lib/configure.js"; +import { + BACKEND_URL, + FALLBACK_MODEL, + MIN_NODE_STRING, + NODE_DOWNLOAD_URL, + nodeVersionMeets, + parseNodeVersion, + RECOMMENDED_NODE, +} from "@/lib/const.js"; +import { + logDebug, + logError, + loggedFetch, + logInfo, + logWarn, + redactSecrets, +} from "@/lib/log.js"; +import { + CLI, + detectInstalledViaNpm, + execAsync, + type NpmTool, + PKG, +} from "@/lib/npm.js"; +import { + backendHost, + hasProxyConfigured, + matchingNoProxyEntry, + type ProxyEnv, + proxyAutoEnabled, + readProxyEnv, +} from "@/lib/proxy.js"; +import { agentOnPath } from "@/lib/run.js"; +import { detectInstalledShims } from "@/lib/shims.js"; +import { isCertError, tlsApi } from "@/lib/tls.js"; + +// --------------------------------------------------------------------------- +// Requirements +// --------------------------------------------------------------------------- + +// The Node floor and its rationale live in lib/const.ts so index.tsx can gate +// on them without importing this module's dependency graph. + +// The internal registry mirror from the install guide. Suggested, never set. +export const INTERNAL_NPM_REGISTRY = + "http://10.60.129.132/repository/npm-proxy"; +const PUBLIC_NPM_REGISTRY = /registry\.npmjs\.org/; + +// The package `codevhub` itself ships as — probed against the registry to prove +// an authenticated read works end-to-end through the proxy. +const SELF_PKG = "codev-ai"; + +// Deliberately more generous than backend.ts's 5s/10s/15s, which are tuned for +// the interactive install flow. Behind a slow proxy those produce spurious +// timeouts, and a pre-flight check that cries wolf is worse than none. +const REACH_TIMEOUT_MS = 20_000; + +// --------------------------------------------------------------------------- +// Check model +// --------------------------------------------------------------------------- + +export type CheckStatus = "pass" | "warn" | "fail" | "skip"; +export type CheckGroup = + | "environment" + | "network" + | "account" + | "llm" + | "state"; + +export interface CheckResult { + status: CheckStatus; + /** One-line outcome. Always shown, whatever the status. */ + detail: string; + /** Remediation. Collected into the final "Next steps" block. */ + fix?: string; + /** Full diagnosis, rendered inline under a failing row. */ + diagnosis?: Diagnosis; +} + +export interface DoctorContext { + /** SSO access token, set once the login check passes. */ + accessToken?: string; + /** Gateway API key, set by the api-key check. */ + apiKey?: string; + /** Gateway base URL, set by the codev-config check. */ + gatewayUrl?: string; + /** Model ids, set by the models check. */ + models?: string[]; + /** Supabase project URL, set by the codev-config check. */ + supabaseUrl?: string; +} + +export interface Check { + key: string; + label: string; + group: CheckGroup; + run: (ctx: DoctorContext) => Promise; +} + +export interface CheckOutcome extends CheckResult { + key: string; + label: string; + group: CheckGroup; +} + +// --------------------------------------------------------------------------- +// Error diagnosis +// +// This is the reason the command exists. `describeNetworkError` (lib/tls.ts) +// unwraps ONE level of err.cause and only special-cases certificate codes; +// everything else still reaches the user as `fetch failed` or +// `fetch failed (connect ECONNREFUSED 10.0.0.1:8080)`, which tells a +// non-engineer nothing. Here we walk the whole chain, name the failure in plain +// language, say what most likely caused it *on this machine*, give the fix, and +// print the raw chain so nothing is hidden from support. +// --------------------------------------------------------------------------- + +export interface Diagnosis { + /** Plain language, not the errno. */ + what: string; + /** Most likely cause, specific to this environment. */ + cause: string; + /** The concrete fix. */ + fix: string; + /** Connection state that determined the outcome. */ + context: string[]; + /** The verbatim error chain, one line per link. */ + raw: string[]; +} + +/** What we were doing when it broke. Drives the context block. */ +export interface Attempt { + url?: string; + method?: string; + timeoutMs?: number; + /** For child processes: the exact command line. */ + command?: string; +} + +interface ErrorLink { + name: string; + message: string; + code?: string; + errno?: number; + syscall?: string; + hostname?: string; + address?: string; + port?: number; +} + +// Node's fetch throws `TypeError: fetch failed` and hides the real reason on +// .cause — sometimes several levels down, sometimes inside an AggregateError +// (one entry per address family when a host resolves to both A and AAAA). +// Flatten the whole thing; the first link carrying a `code` is the real failure. +function errorChain(err: unknown, depth = 0): ErrorLink[] { + if (depth > 8 || !(err instanceof Error)) return []; + const e = err as NodeJS.ErrnoException & { errors?: unknown[] }; + const link: ErrorLink = { + name: err.name, + message: err.message, + code: typeof e.code === "string" ? e.code : undefined, + errno: typeof e.errno === "number" ? e.errno : undefined, + syscall: typeof e.syscall === "string" ? e.syscall : undefined, + hostname: (e as { hostname?: string }).hostname, + address: (e as { address?: string }).address, + port: (e as { port?: number }).port, + }; + const nested: ErrorLink[] = []; + if (Array.isArray(e.errors)) { + for (const inner of e.errors) nested.push(...errorChain(inner, depth + 1)); + } + if (err.cause !== undefined) nested.push(...errorChain(err.cause, depth + 1)); + return [link, ...nested]; +} + +// Codes we know how to explain. Used both to pick the root link and to recover +// a code that only survives in the message text. +const KNOWN_CODES = [ + "ENOTFOUND", + "EAI_AGAIN", + "ECONNREFUSED", + "ETIMEDOUT", + "UND_ERR_CONNECT_TIMEOUT", + "ECONNRESET", + "EPROTO", + "UND_ERR_SOCKET", + "CERT_HAS_EXPIRED", + "ERR_TLS_CERT_ALTNAME_INVALID", +]; + +// Node normally sets `.code` on the underlying error, but code that catches and +// re-throws routinely loses it while keeping the text ("getaddrinfo ENOTFOUND +// host", "connect ECONNREFUSED 10.0.0.1:8080"). Recovering it from the message +// is the difference between a real diagnosis and the generic fallback. +function inferCodeFromMessage(message: string): string | undefined { + return KNOWN_CODES.find((code) => message.includes(code)); +} + +// A bare `Error("getaddrinfo ENOTFOUND api.example.com")` still names the host; +// pull it out so the explanation can say which host failed. +function inferHostFromMessage(message: string): string | undefined { + const dns = /(?:getaddrinfo|queryA\w*)\s+\w+\s+([\w.-]+)/.exec(message); + if (dns?.[1]) return dns[1]; + const conn = /connect\s+\w+\s+([\d.a-f:]+):(\d+)/i.exec(message); + if (conn?.[1]) return conn[1]; + return undefined; +} + +/** + * The first link that carries an OS/undici error code — the real failure. + * + * Falls back to recovering the code (and host/port) from the message text, so + * a re-thrown error that lost its properties still gets a real diagnosis + * instead of the generic one. + */ +function rootLink(chain: ErrorLink[]): ErrorLink | undefined { + const withCode = chain.find((l) => l.code !== undefined); + if (withCode) return withCode; + + for (const link of chain) { + const code = inferCodeFromMessage(link.message); + if (!code) continue; + const conn = /connect\s+\w+\s+([\d.a-f:]+):(\d+)/i.exec(link.message); + return { + ...link, + code, + hostname: link.hostname ?? inferHostFromMessage(link.message), + address: link.address ?? conn?.[1], + port: link.port ?? (conn?.[2] ? Number(conn[2]) : undefined), + }; + } + return chain[chain.length - 1]; +} + +function renderChain(chain: ErrorLink[]): string[] { + return chain.map((link, i) => { + const indent = i === 0 ? "" : `${" ".repeat(i - 1)}└ `; + const facts = [ + link.code && `code ${link.code}`, + link.syscall && `syscall ${link.syscall}`, + link.hostname && `hostname ${link.hostname}`, + link.address && `address ${link.address}`, + link.port !== undefined && `port ${link.port}`, + ].filter(Boolean); + const suffix = facts.length > 0 ? ` (${facts.join(" · ")})` : ""; + return redactSecrets(`${indent}${link.name}: ${link.message}${suffix}`); + }); +} + +/** Where a proxy applies from, for the context block. */ +function proxyDescription(proxy: ProxyEnv): string { + if (!hasProxyConfigured(proxy)) return "proxy: none"; + const parts: string[] = []; + if (proxy.httpsProxy) parts.push(`HTTPS_PROXY=${proxy.httpsProxy}`); + if (proxy.httpProxy) parts.push(`HTTP_PROXY=${proxy.httpProxy}`); + return `proxy: ${parts.join(" · ")}`; +} + +// The single most common silent failure: a proxy is set, but Node was never +// told to use it, so every request goes direct and dies in the firewall. Node +// gives no hint whatsoever that it ignored the variable. +function proxyIgnoredNote(proxy: ProxyEnv): string | null { + if (!hasProxyConfigured(proxy)) return null; + if (proxy.useEnvProxy) return null; + return "NODE_USE_ENV_PROXY is unset — Node is IGNORING your proxy settings"; +} + +function connectionContext(attempt: Attempt): string[] { + const proxy = readProxyEnv(); + const lines: string[] = []; + if (attempt.command) lines.push(`command: ${attempt.command}`); + if (attempt.url) { + const timeout = attempt.timeoutMs + ? ` (timeout ${Math.round(attempt.timeoutMs / 1000)}s)` + : ""; + lines.push( + redactSecrets(`${attempt.method ?? "GET"} ${attempt.url}${timeout}`), + ); + } + const ignored = proxyIgnoredNote(proxy); + lines.push( + `${proxyDescription(proxy)} · NODE_USE_ENV_PROXY: ${ + proxy.useEnvProxy ? "on" : "unset" + }`, + ); + if (ignored) lines.push(`⚠ ${ignored}`); + + const noProxyEntry = matchingNoProxyEntry(proxy, backendHost()); + lines.push( + `NO_PROXY: ${proxy.noProxy ?? "unset"}${ + noProxyEntry ? ` — "${noProxyEntry}" exempts ${backendHost()}` : "" + }`, + ); + lines.push(`node ${process.version} · ${process.platform} ${process.arch}`); + return lines; +} + +// A proxy is configured but Node was told to ignore it — that supersedes every +// other explanation, because nothing else can be diagnosed until it's fixed. +function proxyMisconfigFix(proxy: ProxyEnv): string | null { + if (!hasProxyConfigured(proxy) || proxy.useEnvProxy) return null; + return ( + "Set NODE_USE_ENV_PROXY=1 alongside HTTP_PROXY/HTTPS_PROXY. Node ignores " + + "proxy environment variables without it, and reads it only at startup." + ); +} + +const NO_PROXY_SET_FIX = + "No proxy is configured. If this network requires one, set HTTP_PROXY, " + + "HTTPS_PROXY and NODE_USE_ENV_PROXY=1, then re-run."; + +/** + * Turn any thrown error into a four-part, human-readable diagnosis. + * + * Never returns a bare "fetch failed": every branch produces a plain-language + * `what`, a `cause` specific to this machine's proxy/TLS state, a concrete + * `fix`, and the raw chain for support. + */ +export function diagnoseError(err: unknown, attempt: Attempt = {}): Diagnosis { + const chain = errorChain(err); + const root = rootLink(chain); + const proxy = readProxyEnv(); + const context = connectionContext(attempt); + const raw = + chain.length > 0 ? renderChain(chain) : [redactSecrets(String(err))]; + const host = root?.hostname ?? hostOf(attempt.url) ?? "the server"; + const proxied = hasProxyConfigured(proxy); + const misconfig = proxyMisconfigFix(proxy); + + const base = (what: string, cause: string, fix: string): Diagnosis => ({ + what, + // A configured-but-ignored proxy explains every network symptom below, + // so it wins the "likely cause" slot when present. + cause: misconfig ? `${cause} ${proxyIgnoredNote(proxy)}.` : cause, + fix: misconfig ?? fix, + context, + raw, + }); + + // AbortSignal.timeout produces a DOMException whose message ("The operation + // was aborted due to a timeout") names neither the host nor the duration — + // useless on its own, so rebuild it from the attempt. + if (root?.name === "TimeoutError" || root?.code === "ABORT_ERR") { + const secs = attempt.timeoutMs + ? `${Math.round(attempt.timeoutMs / 1000)}s` + : "the timeout"; + return base( + `No response from ${host} within ${secs}.`, + proxied + ? "The proxy accepted the connection but never returned a response — it may be slow, overloaded, or blocking this host." + : "Traffic to this host is most likely being dropped by a firewall that expects it to go through a proxy.", + proxied + ? "Confirm the proxy address is correct and that it is allowed to reach this host, then re-run." + : NO_PROXY_SET_FIX, + ); + } + + switch (root?.code) { + case "ENOTFOUND": + case "EAI_AGAIN": + return base( + `Could not resolve ${host} (DNS lookup failed).`, + proxied + ? "A proxy is configured, so DNS should be resolved by the proxy — the request appears to have gone direct instead." + : "No proxy is configured and this network's DNS does not resolve external names.", + proxied + ? "Check that the proxy URL is well-formed (http://host:port) and that this host is not listed in NO_PROXY." + : NO_PROXY_SET_FIX, + ); + + case "ECONNREFUSED": + return base( + `Connection refused by ${root.address ?? host}${ + root.port !== undefined ? `:${root.port}` : "" + }.`, + proxied + ? "That address is your proxy — nothing is listening on it, so the proxy host or port is wrong." + : "Nothing accepted a connection on that address.", + proxied + ? "Double-check HTTP_PROXY / HTTPS_PROXY. The value must include the scheme and port, e.g. http://10.0.0.1:8080." + : NO_PROXY_SET_FIX, + ); + + case "ETIMEDOUT": + case "UND_ERR_CONNECT_TIMEOUT": + return base( + `Timed out connecting to ${root.address ?? host}.`, + proxied + ? "The proxy address is reachable in principle but never completed the handshake." + : "A firewall is silently dropping this traffic, which usually means it must go through a proxy.", + proxied + ? "Verify the proxy host and port with your IT team, then re-run." + : NO_PROXY_SET_FIX, + ); + + case "ECONNRESET": + case "EPROTO": + case "UND_ERR_SOCKET": + return base( + `The connection to ${host} was reset mid-handshake.`, + "Typically a TLS-intercepting proxy or HTTPS-scanning antivirus rejecting the request.", + misconfig ?? + "Ask IT for your organization's root CA (a PEM file) and set NODE_EXTRA_CA_CERTS to its path, plus NODE_USE_SYSTEM_CA=1, then re-run.", + ); + + case "CERT_HAS_EXPIRED": + return base( + `${host} presented an expired TLS certificate.`, + "Either the server's certificate genuinely expired, or an intercepting proxy is re-signing with an expired root.", + "Check your system clock is correct. If it is, ask IT — their interception certificate has expired.", + ); + + case "ERR_TLS_CERT_ALTNAME_INVALID": + return base( + `The TLS certificate presented for ${host} is issued for a different hostname.`, + "A proxy is substituting its own certificate without matching the requested host.", + "Ask IT to confirm this host is correctly configured for TLS interception, then re-run.", + ); + + default: + break; + } + + // Certificate trust failures — reuse tls.ts's classification so both paths + // agree on what counts as "untrusted chain". + if (isCertError(err) || root?.code?.includes("CERT")) { + return base( + `Node does not trust the TLS certificate presented for ${host}.`, + "A TLS-intercepting proxy or antivirus is re-signing traffic with a corporate root CA that Node's bundled certificate list does not include.", + `Set NODE_USE_SYSTEM_CA=1 so Node reads your OS trust store. If the root is not in the OS store either, ask IT for the root CA (PEM) and point NODE_EXTRA_CA_CERTS at it.${ + tlsApi.supported() + ? "" + : ` Note: Node ${process.version} cannot read the OS store at all — upgrade to Node ${RECOMMENDED_NODE}.` + }`, + ); + } + + // Unknown code — still never a bare "fetch failed". Name the deepest message + // we found and hand over the raw chain. + return base( + root?.message + ? `Request to ${host} failed: ${redactSecrets(root.message)}` + : `Request to ${host} failed.`, + proxied + ? "A proxy is configured; the failure happened somewhere between this machine, the proxy, and the destination." + : "No proxy is configured. If this network requires one, that is the most likely cause.", + misconfig ?? NO_PROXY_SET_FIX, + ); +} + +function hostOf(url: string | undefined): string | null { + if (!url) return null; + try { + return new URL(url).hostname; + } catch { + return null; + } +} + +// Headers that betray an interceptor sitting between us and the destination. +const INTERCEPTOR_HEADERS = [ + "via", + "server", + "proxy-authenticate", + "www-authenticate", + "x-cache", + "cf-ray", + "x-squid-error", +]; + +/** + * Diagnose a non-2xx HTTP response. + * + * A response is not an exception, so `describeNetworkError` never sees these at + * all — yet a 407 or a proxy-issued 403 is exactly what internal users hit. + */ +export function diagnoseResponse( + status: number, + statusText: string, + headers: Headers, + body: string, + attempt: Attempt = {}, +): Diagnosis { + const context = connectionContext(attempt); + const signature = INTERCEPTOR_HEADERS.map((h) => { + const v = headers.get(h); + return v ? `${h}: ${v}` : null; + }).filter((v): v is string => v !== null); + const snippet = redactSecrets(body.trim()).slice(0, 400); + const raw = [ + `HTTP ${status} ${statusText}`, + ...signature.map(redactSecrets), + ...(snippet ? [snippet + (body.trim().length > 400 ? "…" : "")] : []), + ]; + const host = hostOf(attempt.url) ?? "the server"; + + if (status === 407) { + return { + what: "The proxy requires authentication (HTTP 407).", + cause: `Your proxy is challenging this request: ${ + headers.get("proxy-authenticate") ?? "no scheme advertised" + }.`, + fix: "Include your credentials in the proxy URL, e.g. HTTPS_PROXY=http://user:password@host:port, then re-run.", + context, + raw, + }; + } + + if (status === 403) { + const intercepted = + signature.length > 0 || /proxy|blocked|denied|firewall/i.test(body); + return { + what: `${host} returned HTTP 403 (forbidden).`, + cause: intercepted + ? "The response carries proxy/gateway signatures, so this looks like the network blocking the request rather than a CoDev permissions problem." + : "Either your account lacks access, or a proxy is blocking the request without identifying itself.", + fix: "If you are behind a proxy, this is the documented npm/registry 403: unset HTTP_PROXY and HTTPS_PROXY in the CURRENT shell (do not open a new terminal) and retry.", + context, + raw, + }; + } + + if (status === 502 || status === 503 || status === 504) { + return { + what: `${host} returned HTTP ${status} (${statusText || "upstream error"}).`, + cause: + "Usually the proxy failing to reach the destination, rather than the destination itself being down.", + fix: "Retry shortly. If it persists, confirm with IT that the proxy is allowed to reach this host.", + context, + raw, + }; + } + + if (status === 401) { + return { + what: `${host} rejected the credentials (HTTP 401).`, + cause: "The token or API key sent with this request was not accepted.", + fix: "Run `codevhub login --force` to re-authenticate, then re-run.", + context, + raw, + }; + } + + return { + what: `${host} returned HTTP ${status}${statusText ? ` (${statusText})` : ""}.`, + cause: + signature.length > 0 + ? "The response carries proxy signatures, so a network appliance may have rewritten it." + : "The server rejected the request.", + fix: "See the raw response below; if it does not look like it came from CoDev, a proxy is intercepting the request.", + context, + raw, + }; +} + +/** Diagnose a failed child process (npm, agent CLIs). */ +export function diagnoseExec( + command: string, + exitCode: string | number | null, + stderr: string, + isEnoent: boolean, +): Diagnosis { + // npm's own error text names the registry, proxy and .npmrc in play, so it + // is reproduced in FULL — truncating it destroys the diagnosis. + const raw = redactSecrets(stderr.trim() || "(no stderr)") + .split("\n") + .map((l) => l.trimEnd()); + + if (isEnoent) { + return { + what: `\`${command.split(" ")[0]}\` was not found on your PATH.`, + cause: + "The program is not installed, or its install directory is not on PATH.", + fix: "Install Node.js from nodejs.org (npm ships with it) and open a new terminal so PATH is refreshed.", + context: connectionContext({ command }), + raw, + }; + } + + const proxy = readProxyEnv(); + const lower = stderr.toLowerCase(); + let cause = + "The command ran but exited with an error. The full output is below."; + let fix = "Read the output below — npm names the registry and proxy it used."; + + if (lower.includes("e403") || lower.includes("403 forbidden")) { + cause = + "A 403 from the registry. Behind a corporate proxy this is usually the proxy rejecting the request, not a package permissions problem."; + fix = + "Unset the proxy in the CURRENT shell and retry without opening a new terminal: `unset HTTPS_PROXY HTTP_PROXY` (Linux/macOS) or `$env:HTTPS_PROXY = $null; $env:HTTP_PROXY = $null` (PowerShell)."; + } else if (lower.includes("eacces") || lower.includes("permission denied")) { + cause = "npm could not write to its global install directory."; + fix = + "Point npm at a writable prefix (`npm config set prefix ~/.npm-global`) and add its `bin` directory to PATH, or reinstall Node with a user-owned prefix. Avoid `sudo npm i -g`."; + } else if ( + lower.includes("self-signed") || + lower.includes("self signed") || + lower.includes("unable_to_get_issuer") || + lower.includes("cert") + ) { + cause = + "npm could not verify the registry's TLS certificate — a TLS-intercepting proxy is re-signing the connection."; + fix = + "Point npm at your organization's root CA: `npm config set cafile `. npm keeps its own TLS config, separate from Node's."; + } else if ( + lower.includes("etimedout") || + lower.includes("econnrefused") || + lower.includes("enotfound") || + lower.includes("network") + ) { + cause = hasProxyConfigured(proxy) + ? "npm could not reach the registry. npm has its OWN proxy configuration, separate from Node's environment variables." + : "npm could not reach the registry and no proxy is configured."; + fix = `Set npm's proxy explicitly: \`npm config set proxy \` and \`npm config set https-proxy \`. On the internal network also set the registry mirror: \`npm config set registry ${INTERNAL_NPM_REGISTRY}\`.`; + } + + return { + what: `\`${command}\` failed (exit code ${exitCode ?? "unknown"}).`, + cause, + fix, + context: connectionContext({ command }), + raw, + }; +} + +/** + * Compact single-string rendering, for callers with one line of screen space + * (Login / FetchApiKey's error frames). Keeps the plain-language explanation + * and the fix — the two things `fetch failed` never gave anyone. + */ +export function renderDiagnosisCompact(d: Diagnosis): string { + const lines = [d.what, d.cause, `Fix: ${d.fix}`]; + return lines.join("\n"); +} + +/** + * True when an error came from the transport layer (DNS/TCP/TLS) rather than + * from an HTTP response our own code turned into an Error. + * + * Node's fetch always nests the real reason on `.cause`; a + * `new Error("Backend /config failed (403): forbidden")` thrown by backend.ts + * has none. The distinction matters: the proxy-oriented reasoning in + * `diagnoseError` is exactly right for the former and actively misleading for + * the latter, whose own message is already precise. + */ +export function isTransportError(err: unknown): boolean { + return err instanceof Error && err.cause !== undefined; +} + +/** + * Compact, always-useful failure text for callers with a single error frame: + * a full diagnosis for transport failures, the server's own message otherwise. + */ +export function describeFailure(err: unknown, attempt: Attempt = {}): string { + if (isTransportError(err)) { + return renderDiagnosisCompact(diagnoseError(err, attempt)); + } + return err instanceof Error ? redactSecrets(err.message) : String(err); +} + +// --------------------------------------------------------------------------- +// Probing helpers +// --------------------------------------------------------------------------- + +/** Run a thunk, converting any throw into a `fail` with a full diagnosis. */ +async function guard( + attempt: Attempt, + fn: () => Promise, +): Promise { + try { + return await fn(); + } catch (err) { + const diagnosis = diagnoseError(err, attempt); + return { + status: "fail", + detail: diagnosis.what, + fix: diagnosis.fix, + diagnosis, + }; + } +} + +/** + * Transport-only reachability probe. ANY HTTP response — including 401/403/404 + * — proves the network path works, which is all this is asking. Only DNS/TCP/ + * TLS failures fail it. + */ +async function reach(url: string, label: string): Promise { + const attempt = { url, method: "GET", timeoutMs: REACH_TIMEOUT_MS }; + return guard(attempt, async () => { + const res = await loggedFetch("doctor.reach", url, { + method: "GET", + signal: AbortSignal.timeout(REACH_TIMEOUT_MS), + }); + // 407 is the one status that means the transport is NOT usable. + if (res.status === 407) { + const diagnosis = diagnoseResponse( + res.status, + res.statusText, + res.headers, + await res.text().catch(() => ""), + attempt, + ); + return { + status: "fail", + detail: diagnosis.what, + fix: diagnosis.fix, + diagnosis, + }; + } + return { + status: "pass", + detail: `${label} reachable (HTTP ${res.status}).`, + }; + }); +} + +/** `npm config get `, or null when npm can't answer. */ +async function npmConfig(key: string): Promise { + const r = await execAsync("npm", ["config", "get", key]); + if (r.error) return null; + const value = r.stdout.trim(); + // npm prints the literal string "undefined"/"null" for unset keys. + if (!value || value === "undefined" || value === "null") return null; + return value; +} + +// --------------------------------------------------------------------------- +// Group 1 — environment (synchronous, no network) +// --------------------------------------------------------------------------- + +const nodeVersionCheck: Check = { + key: "node-version", + label: "Node.js version", + group: "environment", + run: async () => { + const version = process.versions.node; + if (!nodeVersionMeets(version)) { + return { + status: "fail", + detail: `Node ${version} is too old — CoDev requires >= ${MIN_NODE_STRING}.`, + fix: `Install Node ${RECOMMENDED_NODE} from ${NODE_DOWNLOAD_URL} and re-run.`, + diagnosis: { + what: `Node ${version} is below the required ${MIN_NODE_STRING}.`, + cause: + "Support for HTTP_PROXY/HTTPS_PROXY was added to the Node 22 line in 22.21.0. Below that, Node silently ignores proxy environment variables, so sign-in can never work behind a corporate proxy no matter how you configure it.", + fix: `Install Node ${RECOMMENDED_NODE} from ${NODE_DOWNLOAD_URL}, open a new terminal, and re-run \`codevhub doctor\`.`, + context: [ + `node ${process.version} · ${process.platform} ${process.arch}`, + `required >= ${MIN_NODE_STRING} · recommended ${RECOMMENDED_NODE}`, + ], + raw: [`process.versions.node = ${version}`], + }, + }; + } + const [major] = parseNodeVersion(version); + if (major < 24) { + return { + status: "pass", + detail: `Node ${version} meets the minimum (Node ${RECOMMENDED_NODE} recommended).`, + }; + } + return { status: "pass", detail: `Node ${version}.` }; + }, +}; + +const npmAvailableCheck: Check = { + key: "npm-available", + label: "npm available", + group: "environment", + run: async () => { + const r = await execAsync("npm", ["-v"]); + if (r.error) { + const diagnosis = diagnoseExec( + "npm -v", + r.error.code ?? null, + r.stderr, + r.error.code === "ENOENT", + ); + return { + status: "fail", + detail: diagnosis.what, + fix: diagnosis.fix, + diagnosis, + }; + } + return { status: "pass", detail: `npm ${r.stdout.trim()}.` }; + }, +}; + +// The two most common global-install failures, both documented: the prefix bin +// directory missing from PATH (Windows "PowerShell can't find the package") and +// EACCES writing into it. +const npmPrefixCheck: Check = { + key: "npm-prefix", + label: "npm global prefix", + group: "environment", + run: async () => { + const prefix = await npmConfig("prefix"); + if (!prefix) { + return { + status: "warn", + detail: "Could not read `npm config get prefix`.", + fix: "Confirm npm is installed and working (`npm -v`).", + }; + } + // npm puts global bins in /bin on Unix and directly in + // on Windows. + const binDir = process.platform === "win32" ? prefix : join(prefix, "bin"); + const onPath = (process.env.PATH ?? "") + .split(delimiter) + .some((dir) => dir && normalizePath(dir) === normalizePath(binDir)); + + let writable = true; + try { + accessSync(existsSync(binDir) ? binDir : prefix, fsConstants.W_OK); + } catch { + writable = false; + } + + if (!writable) { + return { + status: "fail", + detail: `npm's global directory is not writable: ${binDir}`, + fix: "Point npm at a writable prefix (`npm config set prefix ~/.npm-global`) and add its bin directory to PATH. Avoid `sudo npm i -g`, which creates root-owned files.", + diagnosis: { + what: `\`npm i -g\` cannot write to ${binDir}.`, + cause: + "The global prefix is owned by another user (commonly root, after an earlier `sudo npm i -g`), so installing agents will fail with EACCES.", + fix: "Run `npm config set prefix ~/.npm-global`, add `~/.npm-global/bin` to PATH, open a new terminal, and re-run.", + context: [`prefix: ${prefix}`, `bin: ${binDir}`], + raw: [`accessSync(${binDir}, W_OK) threw`], + }, + }; + } + + if (!onPath) { + return { + status: "warn", + detail: `npm's global bin directory is not on PATH: ${binDir}`, + fix: + process.platform === "win32" + ? 'Add it in PowerShell: $npmPath = npm config get prefix; [Environment]::SetEnvironmentVariable("PATH", "$([Environment]::GetEnvironmentVariable(\'PATH\',\'User\'));$npmPath", "User") — then open a new terminal.' + : `Add it to your shell profile: export PATH="${binDir}:$PATH" — then open a new terminal.`, + }; + } + + return { status: "pass", detail: `${binDir} (on PATH, writable).` }; + }, +}; + +function normalizePath(p: string): string { + const trimmed = p.replace(/[\\/]+$/, ""); + return process.platform === "win32" ? trimmed.toLowerCase() : trimmed; +} + +const npmRegistryCheck: Check = { + key: "npm-registry", + label: "npm registry configuration", + group: "environment", + run: async () => { + const [registry, proxy, httpsProxy, strictSsl, cafile] = await Promise.all([ + npmConfig("registry"), + npmConfig("proxy"), + npmConfig("https-proxy"), + npmConfig("strict-ssl"), + npmConfig("cafile"), + ]); + const parts = [`registry: ${registry ?? "unset"}`]; + if (proxy) parts.push(`proxy: ${proxy}`); + if (httpsProxy) parts.push(`https-proxy: ${httpsProxy}`); + if (cafile) parts.push(`cafile: ${cafile}`); + if (strictSsl === "false") parts.push("strict-ssl: false"); + const detail = parts.join(" · "); + + // npm keeps its own proxy/TLS configuration entirely separate from Node's + // environment variables — a working `codevhub` says nothing about whether + // `npm i -g` will work. + const env = readProxyEnv(); + if (hasProxyConfigured(env) && !proxy && !httpsProxy) { + return { + status: "warn", + detail, + fix: `Your shell has a proxy but npm does not — npm keeps its own config. Run: npm config set proxy ${env.httpsProxy ?? env.httpProxy} && npm config set https-proxy ${env.httpsProxy ?? env.httpProxy}`, + }; + } + if (registry && PUBLIC_NPM_REGISTRY.test(registry)) { + return { + status: "warn", + detail, + fix: `On the internal network the public registry is usually unreachable. Set the mirror: npm config set registry ${INTERNAL_NPM_REGISTRY}`, + }; + } + return { status: "pass", detail }; + }, +}; + +const proxyEnvCheck: Check = { + key: "proxy-env", + label: "Proxy & TLS environment", + group: "environment", + run: async () => { + const env = readProxyEnv(); + const host = backendHost(); + const parts = [ + `HTTP_PROXY: ${env.httpProxy ?? "unset"}`, + `HTTPS_PROXY: ${env.httpsProxy ?? "unset"}`, + `NO_PROXY: ${env.noProxy ?? "unset"}`, + `NODE_USE_ENV_PROXY: ${env.useEnvProxy ? "on" : "unset"}`, + `NODE_USE_SYSTEM_CA: ${env.useSystemCa ? "on" : "unset"}`, + ]; + if (env.tlsRejectUnauthorized !== null) { + parts.push(`NODE_TLS_REJECT_UNAUTHORIZED: ${env.tlsRejectUnauthorized}`); + } + const detail = parts.join(" · "); + + const problems: string[] = []; + if (hasProxyConfigured(env) && proxyAutoEnabled()) { + // We turned it on for this process, so proxying genuinely works right + // now — but npm and the agents are separate processes that inherit the + // user's shell, not ours. + problems.push( + "NODE_USE_ENV_PROXY was not set, so CoDev enabled proxy support for this run. Set NODE_USE_ENV_PROXY=1 in your shell so npm and the agents get it too.", + ); + } else if (hasProxyConfigured(env) && !env.useEnvProxy) { + problems.push( + "A proxy is set but NODE_USE_ENV_PROXY is not — Node ignores proxy environment variables without it. Set NODE_USE_ENV_PROXY=1.", + ); + } + const noProxyEntry = matchingNoProxyEntry(env, host); + if (noProxyEntry) { + problems.push( + `NO_PROXY contains "${noProxyEntry}", which sends ${host} traffic direct instead of through the proxy. This is the documented cause of "Login failed" — remove that entry.`, + ); + } + if (env.tlsRejectUnauthorized === "0") { + problems.push( + "NODE_TLS_REJECT_UNAUTHORIZED=0 disables TLS verification for every connection. It works, but it is a troubleshooting setting — prefer NODE_USE_SYSTEM_CA=1 or NODE_EXTRA_CA_CERTS once your root CA is available.", + ); + } + if (hasProxyConfigured(env) && !env.useSystemCa) { + problems.push( + "A proxy is set but NODE_USE_SYSTEM_CA is not. Intercepting proxies re-sign TLS with a corporate root that Node does not trust by default; set NODE_USE_SYSTEM_CA=1.", + ); + } + + if (problems.length === 0) { + return { status: "pass", detail }; + } + return { status: "warn", detail, fix: problems.join("\n") }; + }, +}; + +const systemCaCheck: Check = { + key: "system-ca", + label: "System certificate store", + group: "environment", + run: async () => { + if (!tlsApi.supported()) { + return { + status: "warn", + detail: `Node ${process.version} cannot read the OS certificate store.`, + fix: `Upgrade to Node ${RECOMMENDED_NODE}, or set NODE_EXTRA_CA_CERTS to your organization's root CA (a PEM file).`, + }; + } + let count = 0; + try { + count = tlsApi.getCACertificates("system").length; + } catch { + count = 0; + } + if (count === 0) { + return { + status: "warn", + detail: "The OS certificate store is empty or unreadable.", + fix: "If you are behind a TLS-intercepting proxy, ask IT for the root CA (PEM) and set NODE_EXTRA_CA_CERTS to its path.", + }; + } + return { + status: "pass", + detail: `${count} certificates readable from the OS trust store.`, + }; + }, +}; + +/** + * The subset cheap enough to run at the head of `codevhub install`. + * + * Strictly pure: reads `process.versions` and `process.env` and nothing else. + * Everything excluded here is excluded for a concrete reason, not for tidiness: + * + * - the npm checks each spawn `npm config get`, ~300ms apiece, and `install` + * runs npm for real moments later anyway — a broken npm surfaces there with + * the same diagnosis; + * - `system-ca` reads the OS trust store, which is ~20ms on macOS but 300ms+ + * on Windows where it BLOCKS THE EVENT LOOP. lib/tls.ts documents exactly + * this: an earlier revision paid that cost eagerly and stalled Ink's render + * timers badly enough to fail timing-sensitive tests that never fetch. + * + * `codevhub doctor` runs the full set, where a second of latency is the point. + */ +export const PREFLIGHT_CHECKS: Check[] = [nodeVersionCheck, proxyEnvCheck]; + +export const ENVIRONMENT_CHECKS: Check[] = [ + nodeVersionCheck, + npmAvailableCheck, + npmPrefixCheck, + npmRegistryCheck, + proxyEnvCheck, + systemCaCheck, +]; + +// --------------------------------------------------------------------------- +// Group 2 — network (transport only, no auth) +// --------------------------------------------------------------------------- + +const backendReachCheck: Check = { + key: "backend-reach", + label: "Reach the CoDev backend", + group: "network", + run: () => reach(BACKEND_URL, backendHost()), +}; + +const npmReachCheck: Check = { + key: "npm-reach", + label: "Reach the npm registry", + group: "network", + run: async () => { + const ping = await execAsync("npm", ["ping"]); + if (ping.error) { + const diagnosis = diagnoseExec( + "npm ping", + ping.error.code ?? null, + ping.stderr, + ping.error.code === "ENOENT", + ); + return { + status: "fail", + detail: diagnosis.what, + fix: diagnosis.fix, + diagnosis, + }; + } + // A ping proves the registry answers; a real metadata read proves the + // proxy will let a package through. + const view = await execAsync("npm", ["view", SELF_PKG, "version"]); + if (view.error) { + const diagnosis = diagnoseExec( + `npm view ${SELF_PKG} version`, + view.error.code ?? null, + view.stderr, + view.error.code === "ENOENT", + ); + return { + status: "fail", + detail: diagnosis.what, + fix: diagnosis.fix, + diagnosis, + }; + } + return { + status: "pass", + detail: `Registry reachable; ${SELF_PKG}@${view.stdout.trim()} is installable.`, + }; + }, +}; + +export const NETWORK_CHECKS: Check[] = [backendReachCheck, npmReachCheck]; + +// --------------------------------------------------------------------------- +// Group 3 — account (needs SSO). The login step itself is the +// component, mounted by DoctorApp; these run once it resolves. +// --------------------------------------------------------------------------- + +const apiKeyCheck: Check = { + key: "api-key", + label: "Fetch a gateway API key", + group: "account", + run: async (ctx) => { + if (!ctx.accessToken) { + return { status: "skip", detail: "Skipped — sign-in did not complete." }; + } + const attempt = { url: `${BACKEND_URL}/auth/exchange`, method: "POST" }; + return guard(attempt, async () => { + const key = await fetchApiKey(ctx.accessToken as string); + if (!key) { + return { + status: "fail", + detail: "The backend returned an empty API key.", + fix: "Your account may not be provisioned for the gateway yet — contact the CoDev team.", + }; + } + ctx.apiKey = key; + return { status: "pass", detail: "API key issued." }; + }); + }, +}; + +const configCheck: Check = { + key: "codev-config", + label: "Fetch CoDev configuration", + group: "account", + run: async (ctx) => { + if (!ctx.accessToken) { + return { status: "skip", detail: "Skipped — sign-in did not complete." }; + } + const attempt = { url: `${BACKEND_URL}/config`, method: "POST" }; + return guard(attempt, async () => { + const config = await fetchCodevConfig(ctx.accessToken as string); + ctx.gatewayUrl = config.gatewayUrl; + ctx.supabaseUrl = config.supabaseUrl; + return { + status: "pass", + detail: `Gateway ${config.gatewayUrl}.`, + }; + }); + }, +}; + +const supabaseCheck: Check = { + key: "supabase-reach", + label: "Reach Supabase (log upload)", + group: "account", + run: async (ctx) => { + if (!ctx.accessToken) { + return { status: "skip", detail: "Skipped — sign-in did not complete." }; + } + const attempt = { + url: `${BACKEND_URL}/supabase/exchange`, + method: "POST", + }; + return guard(attempt, async () => { + await fetchSupabaseSession(ctx.accessToken as string); + // Supabase is a different host from the backend and the gateway, so it + // can be blocked independently. `codevhub upload` depends on it. + if (ctx.supabaseUrl) { + const r = await reach(ctx.supabaseUrl, "Supabase"); + if (r.status !== "pass") return r; + } + return { + status: "pass", + detail: "Supabase session issued and reachable.", + }; + }); + }, +}; + +export const ACCOUNT_CHECKS: Check[] = [ + apiKeyCheck, + configCheck, + supabaseCheck, +]; + +// --------------------------------------------------------------------------- +// Group 4 — LLM +// --------------------------------------------------------------------------- + +const gatewayKeyCheck: Check = { + key: "gateway-key", + label: "Validate the gateway key", + group: "llm", + run: async (ctx) => { + if (!ctx.apiKey) { + return { status: "skip", detail: "Skipped — no API key to validate." }; + } + const attempt = { url: `${ctx.gatewayUrl ?? ""}/key/info`, method: "GET" }; + return guard(attempt, async () => { + const ok = await validateApiKey(ctx.apiKey as string); + return ok + ? { status: "pass", detail: "Key accepted by the gateway." } + : { + status: "fail", + detail: "The gateway rejected the key (401/403).", + fix: "Run `codevhub login --force` to mint a fresh key, then re-run.", + }; + }); + }, +}; + +const modelsCheck: Check = { + key: "gateway-models", + label: "List available models", + group: "llm", + run: async (ctx) => { + if (!ctx.apiKey) { + return { status: "skip", detail: "Skipped — no API key." }; + } + const attempt = { url: `${ctx.gatewayUrl ?? ""}/v1/models`, method: "GET" }; + return guard(attempt, async () => { + const models = await fetchModels(ctx.apiKey as string); + ctx.models = models; + const preview = models.slice(0, 3).join(", "); + return { + status: "pass", + detail: `${models.length} models available (${preview}${ + models.length > 3 ? ", …" : "" + }).`, + }; + }); + }, +}; + +// The only check that proves inference is actually permitted: /key/info and +// /v1/models both succeed for a key that is then 403'd on every completion. +const completionCheck: Check = { + key: "llm-completion", + label: "Send a test request to the LLM", + group: "llm", + run: async (ctx) => { + if (!ctx.apiKey) { + return { status: "skip", detail: "Skipped — no API key." }; + } + const model = ctx.models?.[0] ?? FALLBACK_MODEL; + // smokeTestModel never throws — it returns a reason string. + const reason = await smokeTestModel(ctx.apiKey, model); + if (!reason) { + return { status: "pass", detail: `${model} answered a test prompt.` }; + } + return { + status: "fail", + detail: reason, + fix: "The key exists but cannot run this model. Check model entitlement, budget, and any region/IP restrictions with the CoDev team.", + diagnosis: { + what: `The gateway refused a real completion for ${model}.`, + cause: + "Listing models only proves the key exists. This is the error your agents would hit on their first message — model entitlement, an exhausted budget, or an edge/WAF block.", + fix: "Contact the CoDev team with the response below; it distinguishes a permissions problem from a budget one.", + context: connectionContext({ + url: `${ctx.gatewayUrl ?? ""}/v1/chat/completions`, + method: "POST", + }), + raw: [redactSecrets(reason)], + }, + }; + }, +}; + +export const LLM_CHECKS: Check[] = [ + gatewayKeyCheck, + modelsCheck, + completionCheck, +]; + +// --------------------------------------------------------------------------- +// Group 5 — state (informational; never fails, never blocks) +// --------------------------------------------------------------------------- + +const NPM_TOOLS: NpmTool[] = ["codev-code", "claude-code", "codex", "opencode"]; + +const installedAgentsCheck: Check = { + key: "installed-agents", + label: "Agents installed", + group: "state", + run: async () => { + const found: string[] = []; + for (const tool of NPM_TOOLS) { + if (await detectInstalledViaNpm(tool)) { + found.push(`${PKG[tool]} (${CLI[tool]})`); + } + } + return found.length > 0 + ? { status: "pass", detail: found.join(", ") } + : { + status: "skip", + detail: "None — `codevhub install` has not run yet.", + }; + }, +}; + +const configuredAgentsCheck: Check = { + key: "configured-agents", + label: "CoDev-managed configs", + group: "state", + run: async () => { + const tools = detectConfiguredTools(); + if (tools.length === 0) { + return { status: "skip", detail: "None." }; + } + const withBackups = tools.filter((tool: Tool) => + getBackupStatus(tool).some((s) => s.hasBackup), + ); + return { + status: "pass", + detail: `${tools.join(", ")}${ + withBackups.length > 0 + ? ` · backups exist for ${withBackups.join(", ")}` + : " · no backups" + }`, + }; + }, +}; + +const shimsCheck: Check = { + key: "shims", + label: "PATH shims", + group: "state", + run: async () => { + const shims = detectInstalledShims(); + return shims.length > 0 + ? { status: "pass", detail: shims.join(", ") } + : { status: "skip", detail: "None installed." }; + }, +}; + +const editorCliCheck: Check = { + key: "editor-clis", + label: "Editor CLIs", + group: "state", + run: async () => { + const found = ["code", "idea", "pycharm", "webstorm"].filter((cmd) => + agentOnPath(cmd), + ); + return found.length > 0 + ? { status: "pass", detail: `${found.join(", ")} on PATH.` } + : { + status: "skip", + detail: + "None on PATH — editor extension installs will be skipped with a warning.", + }; + }, +}; + +const savedKeyCheck: Check = { + key: "saved-key", + label: "Saved credentials", + group: "state", + run: async () => { + const saved = loadApiKey(); + if (!saved?.apiKey) { + return { status: "skip", detail: "No API key saved yet." }; + } + return { + status: "pass", + detail: `Key saved${saved.model ? ` · model ${saved.model}` : ""}${ + saved.providerName ? ` · provider ${saved.providerName}` : "" + }.`, + }; + }, +}; + +export const STATE_CHECKS: Check[] = [ + installedAgentsCheck, + configuredAgentsCheck, + shimsCheck, + editorCliCheck, + savedKeyCheck, +]; + +// --------------------------------------------------------------------------- +// Runner +// --------------------------------------------------------------------------- + +/** + * Run a group of checks in order, mirroring each result into the diagnostic + * log. A check must never throw — but if one somehow does, it becomes a `fail` + * rather than taking the whole command down. + */ +export async function runChecks( + checks: Check[], + ctx: DoctorContext, + onResult?: (outcome: CheckOutcome) => void, +): Promise { + const outcomes: CheckOutcome[] = []; + for (const check of checks) { + let result: CheckResult; + try { + result = await check.run(ctx); + } catch (err) { + const diagnosis = diagnoseError(err); + result = { + status: "fail", + detail: diagnosis.what, + fix: diagnosis.fix, + diagnosis, + }; + } + const outcome: CheckOutcome = { + ...result, + key: check.key, + label: check.label, + group: check.group, + }; + logCheck(outcome); + outcomes.push(outcome); + onResult?.(outcome); + } + return outcomes; +} + +function logCheck(outcome: CheckOutcome): void { + // Everything goes through `extra`, never `unsafeUnredacted` — raw error + // bodies can carry bearer tokens and must stay scrubbed on disk. + const fields = { + action: "doctor.check", + outcome: + outcome.status === "pass" + ? ("success" as const) + : outcome.status === "fail" + ? ("failure" as const) + : undefined, + extra: { + key: outcome.key, + group: outcome.group, + status: outcome.status, + detail: outcome.detail, + fix: outcome.fix, + diagnosis_raw: outcome.diagnosis?.raw, + diagnosis_context: outcome.diagnosis?.context, + }, + }; + const msg = `doctor ${outcome.key}: ${outcome.status}`; + if (outcome.status === "fail") logError(msg, fields); + else if (outcome.status === "warn") logWarn(msg, fields); + else if (outcome.status === "skip") logDebug(msg, fields); + else logInfo(msg, fields); +} + +export function hasFailure(outcomes: CheckOutcome[]): boolean { + return outcomes.some((o) => o.status === "fail"); +} + +// --------------------------------------------------------------------------- +// Remediation summary +// --------------------------------------------------------------------------- + +/** + * Per-platform instructions for making proxy settings permanent, so the user + * can set them up BEFORE running `codevhub install`. Mirrors the install guide. + */ +export function persistProxyInstructions(proxy?: { + http?: string; + https?: string; +}): string[] { + const value = proxy?.https ?? proxy?.http ?? ":"; + const url = /^https?:\/\//.test(value) ? value : `http://${value}`; + if (process.platform === "win32") { + return [ + "Windows (PowerShell) — set these for your user, then open a new terminal:", + ` [Environment]::SetEnvironmentVariable("HTTP_PROXY", "${url}", "User")`, + ` [Environment]::SetEnvironmentVariable("HTTPS_PROXY", "${url}", "User")`, + ' [Environment]::SetEnvironmentVariable("NODE_USE_ENV_PROXY", "1", "User")', + ' [Environment]::SetEnvironmentVariable("NODE_USE_SYSTEM_CA", "1", "User")', + "", + "npm keeps its own proxy configuration, separate from the above:", + ` npm config set proxy ${url}`, + ` npm config set https-proxy ${url}`, + ` npm config set registry ${INTERNAL_NPM_REGISTRY}`, + ]; + } + return [ + "Linux / macOS — add these to ~/.bashrc or ~/.zshrc, then `source` it:", + ` export HTTP_PROXY=${url}`, + ` export HTTPS_PROXY=${url}`, + " export NODE_USE_ENV_PROXY=1", + " export NODE_USE_SYSTEM_CA=1", + "", + "npm keeps its own proxy configuration, separate from the above:", + ` npm config set proxy ${url}`, + ` npm config set https-proxy ${url}`, + ` npm config set registry ${INTERNAL_NPM_REGISTRY}`, + ]; +} + +/** + * The "Next steps" block: every unresolved issue in order, then the persistent + * setup instructions. Printed whenever anything failed or warned, because the + * point of the command is to leave the user able to fix things before + * `codevhub install`. + */ +export function buildNextSteps( + outcomes: CheckOutcome[], + proxy?: { http?: string; https?: string }, +): string[] { + const actionable = outcomes.filter( + (o) => (o.status === "fail" || o.status === "warn") && o.fix, + ); + if (actionable.length === 0) return []; + + const lines: string[] = []; + for (const [i, o] of actionable.entries()) { + lines.push( + `${i + 1}. ${o.label} — ${o.status === "fail" ? "FAILED" : "warning"}`, + ); + for (const fixLine of (o.fix ?? "").split("\n")) { + lines.push(` ${fixLine}`); + } + } + + const proxyRelated = outcomes.some( + (o) => + o.status !== "pass" && + (o.key === "proxy-env" || + o.group === "network" || + o.group === "account" || + o.group === "llm"), + ); + if (proxyRelated) { + lines.push(""); + lines.push(...persistProxyInstructions(proxy)); + } + + lines.push(""); + lines.push( + "Re-run `codevhub doctor` to confirm, then run `codevhub install`.", + ); + return lines; +} + +// --------------------------------------------------------------------------- +// Re-exec handoff +// +// DoctorApp cannot spawn the retry itself: spawnSync with inherited stdio while +// Ink still owns the TTY corrupts the terminal. Instead the app records its +// intent here and exits; index.tsx reads it after waitUntilExit() resolves. +// Same shape of indirection as reexec.ts. +// --------------------------------------------------------------------------- + +export interface DoctorOutcome { + exitCode: number; + /** Set when the user supplied a proxy and wants the checks re-run with it. */ + retryWithProxy: { http: string; https: string } | null; +} + +export const doctorOutcome: DoctorOutcome = { + exitCode: 0, + retryWithProxy: null, +}; + +export function resetDoctorOutcome(): void { + doctorOutcome.exitCode = 0; + doctorOutcome.retryWithProxy = null; +} + +/** Normalize a user-typed `host:port` into a proxy URL. */ +export function normalizeProxyInput(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + const withScheme = /^https?:\/\//i.test(trimmed) + ? trimmed + : `http://${trimmed}`; + try { + const url = new URL(withScheme); + if (!url.hostname) return null; + return url.toString().replace(/\/$/, ""); + } catch { + return null; + } +} + +/** Set when doctor has already retried, so the prompt is offered only once. */ +export const DOCTOR_PROXY_ENV = "CODEV_DOCTOR_PROXY"; + +export function alreadyRetriedWithProxy(): boolean { + const v = process.env[DOCTOR_PROXY_ENV]; + return v !== undefined && v !== "" && v !== "0"; +} diff --git a/src/lib/help.ts b/src/lib/help.ts index 4f83448..7eb6b40 100644 --- a/src/lib/help.ts +++ b/src/lib/help.ts @@ -15,6 +15,9 @@ passed through to it as well — \`codevhub run "fix the tests"\`, \`codevhub serve\`, \`codevhub models\`, and so on. Hub commands: + doctor Check your environment and network before installing + (Node version, npm, proxy/TLS, sign-in, LLM access; + --force to test a real sign-in instead of the cached one) install Install and configure AI coding agents config Configure existing AI coding agents update Update installed AI coding agents diff --git a/src/lib/log.ts b/src/lib/log.ts index 0f10b8c..b58939e 100644 --- a/src/lib/log.ts +++ b/src/lib/log.ts @@ -491,6 +491,14 @@ function scrubLine(line: string): string { return out; } +// The same value-shape scrubbing, exposed for text that is about to be printed +// to the TERMINAL rather than written to disk — `codevhub doctor` echoes raw +// error bodies and response headers, and users paste that output into chat. +// Sharing SCRUB_PATTERNS keeps one definition of "this must never be shown". +export function redactSecrets(text: string): string { + return scrubLine(text); +} + export interface PruneLimits { maxAgeDays?: number; maxTotalBytes?: number; diff --git a/src/lib/proxy.ts b/src/lib/proxy.ts new file mode 100644 index 0000000..9e4442c --- /dev/null +++ b/src/lib/proxy.ts @@ -0,0 +1,281 @@ +import http from "node:http"; +import { BACKEND_URL } from "@/lib/const.js"; +import { currentTraceId, logInfo, logWarn } from "@/lib/log.js"; +import { spawner } from "@/lib/reexec.js"; + +// Node does NOT honor HTTP_PROXY/HTTPS_PROXY by default. Support is gated behind +// NODE_USE_ENV_PROXY=1 (or --use-env-proxy) and it is read at *bootstrap*, so +// assigning process.env mid-run is too late for the already-initialized global +// dispatcher. Users who follow our install docs and export only +// HTTP_PROXY/HTTPS_PROXY therefore get no proxy at all — silently — and every +// fetch fails with a bare `fetch failed`. +// +// This module closes that gap once, at the CLI entry point, so every command +// (install, login, upload, model, the launch-time key refresh) benefits rather +// than just the one being debugged. +// +// Both the env-var support and http.setGlobalProxyFromEnv() landed together in +// Node 22.21.0 / 24.x — which is exactly why MIN_NODE_VERSION is 22.21.0. + +// Indirection so tests can simulate a Node that predates the API (mirrors +// `tlsApi` in tls.ts / `spawner` in reexec.ts). Accessed off the default import +// and never destructured: on older Node this export doesn't exist, and a named +// ESM import of a missing builtin export is a link-time error, not a runtime +// `undefined`. +export const httpApi = { + setGlobalProxyFromEnv: (): void => { + ( + http as unknown as { setGlobalProxyFromEnv?: () => void } + ).setGlobalProxyFromEnv?.(); + }, + supported: (): boolean => + typeof (http as unknown as Record) + .setGlobalProxyFromEnv === "function", +}; + +// Set on the re-exec child so a Node that somehow still ignores the env var +// can't send us round the loop forever (same guard shape as reexec.ts's +// `process.execArgv.includes("--experimental-sqlite")` check). +export const PROXY_APPLIED_ENV = "CODEV_PROXY_APPLIED"; + +export interface ProxyEnv { + /** Raw HTTP_PROXY / http_proxy value, whichever is set. */ + httpProxy: string | null; + httpsProxy: string | null; + noProxy: string | null; + /** NODE_USE_ENV_PROXY is set to something truthy. */ + useEnvProxy: boolean; + /** NODE_USE_SYSTEM_CA is set to something truthy. */ + useSystemCa: boolean; + /** Raw NODE_TLS_REJECT_UNAUTHORIZED, so "0" can be reported verbatim. */ + tlsRejectUnauthorized: string | null; +} + +// Node accepts either case for the proxy vars (and lowercase is the long- +// standing Unix convention), so read both rather than trusting the docs' casing. +// Each spelling is normalized independently: an empty HTTP_PROXY means "unset" +// and must fall through to http_proxy. A plain `env[upper] ?? env[lower]` only +// falls through on `undefined`, so an exported-but-empty variable would mask a +// perfectly good lowercase one. +function readEither( + env: NodeJS.ProcessEnv, + upper: string, + lower: string, +): string | null { + return nonEmpty(env[upper]) ?? nonEmpty(env[lower]); +} + +function nonEmpty(value: string | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +// "1"/"true"/"yes" style. Node itself treats any non-empty value as on, so an +// explicit "0" is deliberately still "set" here — we report what the user did +// rather than second-guessing it. +function isEnabled(value: string | undefined): boolean { + if (value === undefined) return false; + const v = value.trim().toLowerCase(); + return v !== "" && v !== "0" && v !== "false" && v !== "no"; +} + +export function readProxyEnv(env: NodeJS.ProcessEnv = process.env): ProxyEnv { + return { + httpProxy: readEither(env, "HTTP_PROXY", "http_proxy"), + httpsProxy: readEither(env, "HTTPS_PROXY", "https_proxy"), + noProxy: readEither(env, "NO_PROXY", "no_proxy"), + useEnvProxy: isEnabled(env.NODE_USE_ENV_PROXY), + useSystemCa: isEnabled(env.NODE_USE_SYSTEM_CA), + tlsRejectUnauthorized: env.NODE_TLS_REJECT_UNAUTHORIZED ?? null, + }; +} + +export function hasProxyConfigured(proxy: ProxyEnv): boolean { + return proxy.httpProxy !== null || proxy.httpsProxy !== null; +} + +/** The host CoDev's backend, SSO wrapper and skill hub all live on. */ +export function backendHost(): string { + try { + return new URL(BACKEND_URL).hostname; + } catch { + return ""; + } +} + +// Standard NO_PROXY matching: comma-separated entries, `*` means everything, a +// leading dot or `*.` is a suffix match, an optional `:port` suffix is ignored +// for our purposes (we only ever talk https). +export function noProxyEntryMatches(entry: string, host: string): boolean { + const clean = entry.trim().toLowerCase().replace(/:\d+$/, ""); + if (!clean) return false; + if (clean === "*") return true; + const target = host.toLowerCase(); + if (clean.startsWith("*.")) return target.endsWith(clean.slice(1)); + if (clean.startsWith(".")) return target.endsWith(clean); + return target === clean || target.endsWith(`.${clean}`); +} + +/** + * The NO_PROXY entry that would exempt `host` from the proxy, or null. + * + * This is the documented cause of the "Login failed" symptom in the install + * guide: internal images ship with `*.viettel.vn` in NO_PROXY, which sends our + * backend traffic direct — straight into the firewall — while every other + * request correctly goes through the proxy. + */ +export function matchingNoProxyEntry( + proxy: ProxyEnv, + host: string, +): string | null { + if (!proxy.noProxy || !host) return null; + for (const entry of proxy.noProxy.split(",")) { + if (noProxyEntryMatches(entry, host)) return entry.trim(); + } + return null; +} + +/** NO_PROXY with every entry that would exempt `host` removed. */ +export function stripNoProxyFor(noProxy: string, host: string): string { + return noProxy + .split(",") + .filter((entry) => entry.trim() && !noProxyEntryMatches(entry, host)) + .map((entry) => entry.trim()) + .join(","); +} + +export type ProxyAction = + // No HTTP_PROXY/HTTPS_PROXY set — nothing to do. + | "none" + // Already active: the user set NODE_USE_ENV_PROXY themselves, or we are the + // re-exec child. + | "already-active" + // Enabled in-process via http.setGlobalProxyFromEnv(). + | "applied" + // Re-executed with NODE_USE_ENV_PROXY=1; the caller must exit. + | "reexec" + // We tried and could not enable it. Non-fatal: the command proceeds and + // fails with a diagnosable network error rather than a mystery. + | "failed"; + +// True once we enabled proxying ourselves, as opposed to the user having set +// NODE_USE_ENV_PROXY. The distinction only matters for advice: proxying works +// either way for *this* process, but npm and the agents are separate processes +// and still need the variable set in the user's shell. +let autoEnabled = false; + +export function proxyAutoEnabled(): boolean { + return autoEnabled; +} + +/** Test-only: forget that we enabled the proxy so each case starts clean. */ +export function resetProxyState(): void { + autoEnabled = false; +} + +export interface ProxyResult { + action: ProxyAction; + exitCode?: number; + /** Set when NO_PROXY would send backend traffic direct. */ + noProxyWarning?: string; + error?: string; +} + +/** + * Make `fetch` honor HTTP_PROXY/HTTPS_PROXY for this process. + * + * Called from index.tsx right after initLogging and before command dispatch. + * Strictly best-effort: any failure returns "failed" and the command runs + * anyway — a proxy we couldn't enable must not stop `codevhub doctor` from + * running and explaining why the network is broken. + */ +export function applyEnvProxy(): ProxyResult { + const proxy = readProxyEnv(); + + // A NO_PROXY exemption for our own backend defeats the proxy no matter how + // it is enabled, so surface it on every path below (including "none", where + // the user has no proxy but may still have inherited the entry). + const noProxyEntry = matchingNoProxyEntry(proxy, backendHost()); + const noProxyWarning = noProxyEntry + ? `NO_PROXY contains "${noProxyEntry}", which sends ${backendHost()} traffic ` + + "direct instead of through your proxy. This is the usual cause of " + + "`Login failed`. Remove that entry and re-run." + : undefined; + + if (!hasProxyConfigured(proxy)) return { action: "none", noProxyWarning }; + if (proxy.useEnvProxy || isEnabled(process.env[PROXY_APPLIED_ENV])) { + return { action: "already-active", noProxyWarning }; + } + + // Fast path: enable it in-process. Verified to route global `fetch`, not + // just node:http/https Agent traffic. + if (httpApi.supported()) { + try { + httpApi.setGlobalProxyFromEnv(); + // Record the truth in the environment. Two reasons this matters: + // 1. Everything downstream reads NODE_USE_ENV_PROXY to decide whether + // the proxy is live. Leaving it unset made diagnoseError claim + // "Node is IGNORING your proxy settings" on a request that had + // demonstrably just gone through the proxy — a message that + // contradicts its own evidence is worse than no message. + // 2. Children we spawn (npm, the agents) inherit it, so they pick up + // the proxy too instead of each needing their own fix. + process.env.NODE_USE_ENV_PROXY = "1"; + autoEnabled = true; + logInfo("enabled proxy from environment", { + action: "proxy.configure", + extra: { + via: "setGlobalProxyFromEnv", + no_proxy_conflict: !!noProxyEntry, + }, + }); + return { action: "applied", noProxyWarning }; + } catch (err) { + logWarn("setGlobalProxyFromEnv failed; falling back to re-exec", { + action: "proxy.configure", + err, + }); + } + } + + return { ...reexecWithEnvProxy(), noProxyWarning }; +} + +// Slow path for Node builds without setGlobalProxyFromEnv: relaunch ourselves +// with NODE_USE_ENV_PROXY=1 so the flag is present at bootstrap, where Node +// actually reads it. stdio is inherited, so the user sees one continuous +// session; we just exit with the child's code. +function reexecWithEnvProxy(): ProxyResult { + const selfPath = process.argv[1]; + if (!selfPath) { + const error = "cannot determine CLI entry path for proxy re-exec"; + logWarn(error, { action: "proxy.configure", outcome: "failure" }); + return { action: "failed", error }; + } + + logInfo("re-executing with NODE_USE_ENV_PROXY=1", { + action: "proxy.configure", + extra: { via: "reexec" }, + }); + // Ink owns the TTY once a command renders, but this runs before dispatch, so + // plain stderr is safe here and the extra process isn't a mystery. + process.stderr.write( + "Detected HTTP_PROXY; restarting with NODE_USE_ENV_PROXY=1 so it takes effect.\n", + ); + + const traceId = currentTraceId(); + const result = spawner.spawnSync( + process.execPath, + [...process.execArgv, selfPath, ...process.argv.slice(2)], + { + stdio: "inherit", + env: { + ...process.env, + NODE_USE_ENV_PROXY: "1", + [PROXY_APPLIED_ENV]: "1", + ...(traceId ? { CODEV_TRACE_PARENT: traceId } : {}), + }, + }, + ); + return { action: "reexec", exitCode: result.status ?? 1 }; +} diff --git a/tests/DoctorApp.test.tsx b/tests/DoctorApp.test.tsx new file mode 100644 index 0000000..0911d5b --- /dev/null +++ b/tests/DoctorApp.test.tsx @@ -0,0 +1,354 @@ +import * as child_process from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { cleanup, render } from "ink-testing-library"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { DoctorApp } from "@/DoctorApp.js"; +import * as auth from "@/lib/auth.js"; +import * as backend from "@/lib/backend.js"; +import { + DOCTOR_PROXY_ENV, + doctorOutcome, + resetDoctorOutcome, +} from "@/lib/doctor.js"; +import * as log from "@/lib/log.js"; +import { PROXY_APPLIED_ENV } from "@/lib/proxy.js"; +import * as reexec from "@/lib/reexec.js"; +import * as tls from "@/lib/tls.js"; + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, execFile: vi.fn() }; +}); + +const PROXY_VARS = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "NO_PROXY", + "no_proxy", + "NODE_USE_ENV_PROXY", + "NODE_USE_SYSTEM_CA", + "NODE_TLS_REJECT_UNAUTHORIZED", + DOCTOR_PROXY_ENV, + PROXY_APPLIED_ENV, +]; + +type ExecCb = (error: Error | null, stdout: string, stderr: string) => void; + +// Mirrors the shape normalization in InstallApp.test.tsx: production uses +// (file, args, opts, cb) on POSIX and a single command string on Windows. +function stubExecFile( + handler: ( + file: string, + args: string[], + ) => { error?: Error | null; stdout?: string; stderr?: string }, +) { + vi.mocked(child_process.execFile).mockImplementation((( + ...callArgs: unknown[] + ) => { + const cb = callArgs[callArgs.length - 1] as ExecCb; + const first = callArgs[0] as string; + const second = callArgs[1]; + let file: string; + let args: string[]; + if (Array.isArray(second)) { + file = first; + args = second as string[]; + } else { + const tokens = first.split(/\s+/).filter(Boolean); + file = tokens[0] ?? ""; + args = tokens.slice(1); + } + const r = handler(file, args); + setImmediate(() => cb(r.error ?? null, r.stdout ?? "", r.stderr ?? "")); + return {} as unknown as child_process.ChildProcess; + }) as unknown as typeof child_process.execFile); +} + +let tempHome: string; + +beforeEach(() => { + tempHome = mkdtempSync(join(tmpdir(), "codev-doctorapp-test-")); + vi.stubEnv("HOME", tempHome); + vi.stubEnv("USERPROFILE", tempHome); + for (const name of PROXY_VARS) vi.stubEnv(name, ""); + resetDoctorOutcome(); + + // Every npm probe answers plausibly by default; individual tests override. + stubExecFile((file, args) => { + if (file !== "npm") return { stdout: "" }; + if (args[0] === "-v") return { stdout: "10.9.0" }; + if (args[0] === "config") return { stdout: "undefined" }; + if (args[0] === "ping") return { stdout: "ok" }; + if (args[0] === "view") return { stdout: "0.4.5" }; + return { stdout: "" }; + }); + // The OS trust store read is slow on Windows and machine-dependent + // everywhere; pin it so the row is deterministic. + vi.spyOn(tls.tlsApi, "supported").mockReturnValue(true); + vi.spyOn(tls.tlsApi, "getCACertificates").mockReturnValue(["cert"]); + // Never let a test reach the real network or re-exec the CLI. + vi.spyOn(log, "loggedFetch").mockResolvedValue( + new Response("", { status: 200 }), + ); + vi.spyOn(reexec.spawner, "spawnSync").mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: minimal SpawnSyncReturns stub + { status: 0 } as any, + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + rmSync(tempHome, { recursive: true, force: true }); +}); + +function fakeAuth(): auth.AuthData { + return { + access_token: "access-xyz", + id_token: "id-xyz", + expires_at: Date.now() + 3_600_000, + user: { sub: "u", email: "test@example.com", displayName: "Test" }, + }; +} + +/** Stub the whole happy path: sign-in, key, config, supabase, gateway, LLM. */ +function stubHappyPath() { + vi.spyOn(auth, "login").mockResolvedValue(fakeAuth()); + vi.spyOn(backend, "fetchApiKey").mockResolvedValue("sk-doctor-123"); + vi.spyOn(backend, "fetchCodevConfig").mockResolvedValue({ + supabaseUrl: "https://supabase.example.com", + supabaseAnonKey: "anon", + gatewayUrl: "https://gateway.example.com", + }); + vi.spyOn(backend, "fetchSupabaseSession").mockResolvedValue({ + access_token: "sb", + user: { id: "u", email: "test@example.com" }, + }); + vi.spyOn(backend, "validateApiKey").mockResolvedValue(true); + vi.spyOn(backend, "fetchModels").mockResolvedValue(["m-alpha", "m-beta"]); + vi.spyOn(backend, "smokeTestModel").mockResolvedValue(null); +} + +// Poll rather than sleep — the flow is a chain of async phases and a fixed +// sleep that passes locally is a Heisenbug on slower CI. +async function waitForFrame( + frames: string[], + needle: string, + maxMs = 5_000, +): Promise { + const start = Date.now(); + while (Date.now() - start < maxMs) { + if (frames.join("\n").includes(needle)) { + await new Promise((r) => setTimeout(r, 30)); + return; + } + await new Promise((r) => setTimeout(r, 20)); + } +} + +describe("DoctorApp", () => { + test("a clean machine reaches the summary and exits 0", async () => { + stubHappyPath(); + const { frames } = render(); + + await waitForFrame(frames, "Everything checks out"); + const output = frames.join("\n"); + + // Every group ran, in order. + expect(output).toContain("Environment"); + expect(output).toContain("Network"); + expect(output).toContain("Signed in as test@example.com"); + expect(output).toContain("LLM access"); + // The LLM completion is the check that proves inference is permitted. + expect(output).toContain("m-alpha answered a test prompt"); + expect(output).toContain("codevhub install"); + expect(doctorOutcome.exitCode).toBe(0); + }); + + test("a failing LLM completion fails the run and explains itself", async () => { + stubHappyPath(); + vi.spyOn(backend, "smokeTestModel").mockResolvedValue( + "Gateway rejected a test request for m-alpha (HTTP 403): over budget", + ); + const { frames } = render(); + + await waitForFrame(frames, "check(s) failed"); + const output = frames.join("\n"); + + expect(output).toContain("What happened"); + expect(output).toContain("Likely cause"); + expect(output).toContain("What to do"); + expect(output).toContain("over budget"); + expect(doctorOutcome.exitCode).toBe(1); + }); + + // The point of the whole command: a network failure must never surface as + // a bare `fetch failed`. + test("a network failure renders the full diagnosis, not `fetch failed`", async () => { + const err = new TypeError("fetch failed"); + (err as Error & { cause?: unknown }).cause = Object.assign( + new Error("getaddrinfo ENOTFOUND netmind.example.com"), + { code: "ENOTFOUND", hostname: "netmind.example.com" }, + ); + vi.spyOn(log, "loggedFetch").mockRejectedValue(err); + stubHappyPath(); + + const { frames } = render(); + await waitForFrame(frames, "Could not resolve"); + const output = frames.join("\n"); + + expect(output).toContain("Could not resolve netmind.example.com"); + expect(output).toContain("What to do"); + // The raw chain is still shown — hiding it would cost support the detail. + expect(output).toContain("code ENOTFOUND"); + }); + + test("a network failure offers the proxy prompt", async () => { + vi.spyOn(log, "loggedFetch").mockRejectedValue( + Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("connect ECONNREFUSED 1.2.3.4:443"), { + code: "ECONNREFUSED", + }), + }), + ); + stubHappyPath(); + + const { frames } = render(); + await waitForFrame(frames, "Proxy (host:port)"); + expect(frames.join("\n")).toContain("Configure a proxy"); + }); + + test("submitting a proxy records the retry for index.tsx to run", async () => { + vi.spyOn(log, "loggedFetch").mockRejectedValue( + Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("connect ECONNREFUSED 1.2.3.4:443"), { + code: "ECONNREFUSED", + }), + }), + ); + stubHappyPath(); + + const { frames, stdin } = render(); + await waitForFrame(frames, "Proxy (host:port)"); + // Give ProxyPrompt's useInput handler a render to register on before + // typing — a keystroke that lands first is simply dropped. + await new Promise((r) => setTimeout(r, 80)); + stdin.write("10.0.0.1:8080"); + await new Promise((r) => setTimeout(r, 50)); + stdin.write("\r"); + await new Promise((r) => setTimeout(r, 100)); + + // The app must NOT spawn the retry itself — Ink still owns the TTY. + expect(doctorOutcome.retryWithProxy).toEqual({ + http: "http://10.0.0.1:8080", + https: "http://10.0.0.1:8080", + }); + expect(reexec.spawner.spawnSync).not.toHaveBeenCalled(); + }); + + test("an empty proxy answer skips the retry and continues to the summary", async () => { + vi.spyOn(log, "loggedFetch").mockRejectedValue( + Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("connect ECONNREFUSED 1.2.3.4:443"), { + code: "ECONNREFUSED", + }), + }), + ); + stubHappyPath(); + + const { frames, stdin } = render(); + await waitForFrame(frames, "Proxy (host:port)"); + stdin.write("\r"); + + await waitForFrame(frames, "check(s) failed"); + expect(doctorOutcome.retryWithProxy).toBeNull(); + // It still gets far enough to hand back setup instructions. + expect(frames.join("\n")).toContain("Next steps"); + }); + + // Offering the prompt again in the retry child would loop the user forever. + test("the retry child is not offered the proxy prompt again", async () => { + vi.stubEnv(DOCTOR_PROXY_ENV, "1"); + vi.spyOn(log, "loggedFetch").mockRejectedValue( + Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("connect ECONNREFUSED 1.2.3.4:443"), { + code: "ECONNREFUSED", + }), + }), + ); + stubHappyPath(); + + const { frames } = render(); + await waitForFrame(frames, "check(s) failed"); + expect(frames.join("\n")).not.toContain("Proxy (host:port)"); + }); + + // A working proxy means the problem is elsewhere; asking for one again + // would just be noise. + test("no prompt when a proxy is already configured and active", async () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + vi.spyOn(log, "loggedFetch").mockRejectedValue( + Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("connect ECONNREFUSED 1.2.3.4:443"), { + code: "ECONNREFUSED", + }), + }), + ); + stubHappyPath(); + + const { frames } = render(); + await waitForFrame(frames, "check(s) failed"); + expect(frames.join("\n")).not.toContain("Proxy (host:port)"); + }); + + // Login must not park the run on a retry prompt — the summary is the value. + test("a sign-in failure is recorded and the run still reaches the summary", async () => { + vi.spyOn(auth, "login").mockRejectedValue( + Object.assign(new TypeError("fetch failed"), { + cause: Object.assign( + new Error("getaddrinfo ENOTFOUND sso.example.com"), + { + code: "ENOTFOUND", + hostname: "sso.example.com", + }, + ), + }), + ); + + const { frames } = render(); + await waitForFrame(frames, "check(s) failed"); + const output = frames.join("\n"); + + expect(output).toContain("Could not resolve sso.example.com"); + // Downstream checks report themselves as skipped, which shows how far the + // flow got rather than silently vanishing. + expect(output).toContain("Skipped"); + expect(output).not.toContain("Press Enter to retry"); + expect(doctorOutcome.exitCode).toBe(1); + }); + + test("environment warnings do not fail the run", async () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + vi.stubEnv("NODE_TLS_REJECT_UNAUTHORIZED", "0"); + stubHappyPath(); + + const { frames } = render(); + await waitForFrame(frames, "warning(s)"); + expect(frames.join("\n")).toContain("NODE_TLS_REJECT_UNAUTHORIZED"); + expect(doctorOutcome.exitCode).toBe(0); + }); + + test("reports what is already installed on this machine", async () => { + stubHappyPath(); + const { frames } = render(); + await waitForFrame(frames, "This machine"); + expect(frames.join("\n")).toContain("This machine"); + }); +}); diff --git a/tests/components/Login.test.tsx b/tests/components/Login.test.tsx index e452266..a895224 100644 --- a/tests/components/Login.test.tsx +++ b/tests/components/Login.test.tsx @@ -128,7 +128,11 @@ describe("Login", () => { expect(onDone).not.toHaveBeenCalled(); }); - test("surfaces err.cause when present (real-world: fetch failed → DNS detail)", async () => { + // Node's fetch throws a bare `TypeError: fetch failed` and hides the reason + // on err.cause. Rather than echoing that, Login now renders the full + // diagnosis: what actually failed, why it most likely happened on this + // machine, and the fix. `fetch failed` must never reach the user alone. + test("renders a full diagnosis for a DNS failure, not `fetch failed`", async () => { vi.spyOn(auth, "login").mockImplementation(() => { const err = new TypeError("fetch failed"); (err as Error & { cause?: unknown }).cause = new Error( @@ -143,9 +147,12 @@ describe("Login", () => { await new Promise((r) => setTimeout(r, 50)); const output = lastFrame() ?? ""; - expect(output).toContain( - "Login failed: fetch failed (getaddrinfo ENOTFOUND sso.example.com)", - ); + // Names the real failure and the host, in plain language. + expect(output).toContain("Could not resolve sso.example.com"); + // And tells the user what to do about it. + expect(output).toContain("NODE_USE_ENV_PROXY"); + // The bare Node message is not what's shown on its own. + expect(output).not.toContain("Login failed: fetch failed"); }); test("keeps the happy path to a one-line spinner until the fallback delay elapses", async () => { diff --git a/tests/lib/const.test.ts b/tests/lib/const.test.ts index d6b48a4..420635d 100644 --- a/tests/lib/const.test.ts +++ b/tests/lib/const.test.ts @@ -10,6 +10,9 @@ import { GATEWAY_COMPACT_TRIGGER, GATEWAY_CONTEXT_WINDOW, GATEWAY_MAX_OUTPUT_TOKENS, + MIN_NODE_STRING, + nodeVersionMeets, + parseNodeVersion, SUPABASE_ANON_KEY, SUPABASE_URL, } from "@/lib/const.js"; @@ -123,3 +126,42 @@ describe("gateway compaction constants", () => { expect(GATEWAY_COMPACT_RESERVED).toBe(29491); }); }); + +// This gate decides whether the CLI runs at all, and the boundary is an +// oddly specific patch release: 22.21.0 is where HTTP_PROXY support was +// backported to the Node 22 line. A naive `major < 22 || minor < 21` compare +// would wrongly reject every 23.x and 24.x, so pin both directions. +describe("Node version gate", () => { + test("parses a v-prefixed version", () => { + expect(parseNodeVersion("v24.15.0")).toEqual([24, 15, 0]); + expect(parseNodeVersion("22.21.0")).toEqual([22, 21, 0]); + }); + + test.each([ + ["22.21.0", true], + ["22.21.1", true], + ["22.22.0", true], + ["v24.15.0", true], + // Newer majors must pass even though their minor is below 21. + ["23.0.0", true], + ["24.0.0", true], + ["24.5.0", true], + ["22.20.9", false], + ["22.5.0", false], + ["22.0.0", false], + ["21.99.99", false], + ["20.11.0", false], + ])("%s meets the floor: %s", (version, expected) => { + expect(nodeVersionMeets(version)).toBe(expected); + }); + + test("the floor string matches what the comparison enforces", () => { + expect(nodeVersionMeets(MIN_NODE_STRING)).toBe(true); + const [major, minor, patch] = parseNodeVersion(MIN_NODE_STRING); + expect(nodeVersionMeets(`${major}.${minor}.${patch - 1}`)).toBe(false); + }); + + test("the suite's own Node satisfies the gate it ships", () => { + expect(nodeVersionMeets(process.versions.node)).toBe(true); + }); +}); diff --git a/tests/lib/doctor.test.ts b/tests/lib/doctor.test.ts new file mode 100644 index 0000000..ac2880d --- /dev/null +++ b/tests/lib/doctor.test.ts @@ -0,0 +1,735 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import * as backend from "@/lib/backend.js"; +import { + buildNextSteps, + type CheckOutcome, + describeFailure, + diagnoseError, + diagnoseExec, + diagnoseResponse, + ENVIRONMENT_CHECKS, + hasFailure, + INTERNAL_NPM_REGISTRY, + isTransportError, + LLM_CHECKS, + normalizeProxyInput, + PREFLIGHT_CHECKS, + persistProxyInstructions, + renderDiagnosisCompact, + runChecks, +} from "@/lib/doctor.js"; +import * as npm from "@/lib/npm.js"; +import * as proxy from "@/lib/proxy.js"; +import { PROXY_APPLIED_ENV } from "@/lib/proxy.js"; +import * as tls from "@/lib/tls.js"; + +const PROXY_VARS = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "NO_PROXY", + "no_proxy", + "NODE_USE_ENV_PROXY", + "NODE_USE_SYSTEM_CA", + "NODE_TLS_REJECT_UNAUTHORIZED", + PROXY_APPLIED_ENV, +]; + +beforeEach(() => { + // A maintainer's own proxy settings would otherwise change which branch of + // every diagnosis is taken. + for (const name of PROXY_VARS) vi.stubEnv(name, ""); + proxy.resetProxyState(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +/** + * Build the shape Node's `fetch` actually throws: a bare `TypeError: fetch + * failed` wrapping the real error, which carries the code and syscall details. + * Hand-rolling `new Error("ECONNREFUSED")` would test a shape that never occurs. + */ +function fetchError( + code: string, + message: string, + extra: Record = {}, +): TypeError { + const outer = new TypeError("fetch failed"); + const inner = Object.assign(new Error(message), { code, ...extra }); + (outer as Error & { cause?: unknown }).cause = inner; + return outer; +} + +function rendered(d: ReturnType): string { + return [d.what, d.cause, d.fix, ...d.context, ...d.raw].join("\n"); +} + +describe("diagnoseError", () => { + // Table-driven: every code we claim to explain must produce all four parts. + const CASES: [string, TypeError, RegExp][] = [ + [ + "ENOTFOUND", + fetchError("ENOTFOUND", "getaddrinfo ENOTFOUND api.example.com", { + syscall: "getaddrinfo", + hostname: "api.example.com", + }), + /could not resolve/i, + ], + [ + "EAI_AGAIN", + fetchError("EAI_AGAIN", "getaddrinfo EAI_AGAIN api.example.com", { + hostname: "api.example.com", + }), + /could not resolve/i, + ], + [ + "ECONNREFUSED", + fetchError("ECONNREFUSED", "connect ECONNREFUSED 10.0.0.1:8080", { + address: "10.0.0.1", + port: 8080, + }), + /connection refused/i, + ], + [ + "ETIMEDOUT", + fetchError("ETIMEDOUT", "connect ETIMEDOUT 10.0.0.1:8080", { + address: "10.0.0.1", + }), + /timed out/i, + ], + [ + "UND_ERR_CONNECT_TIMEOUT", + fetchError("UND_ERR_CONNECT_TIMEOUT", "Connect Timeout Error"), + /timed out/i, + ], + [ + "ECONNRESET", + fetchError("ECONNRESET", "read ECONNRESET"), + /reset mid-handshake/i, + ], + [ + "EPROTO", + fetchError("EPROTO", "write EPROTO ... ssl3_read_bytes"), + /reset mid-handshake/i, + ], + [ + "CERT_HAS_EXPIRED", + fetchError("CERT_HAS_EXPIRED", "certificate has expired"), + /expired TLS certificate/i, + ], + [ + "ERR_TLS_CERT_ALTNAME_INVALID", + fetchError("ERR_TLS_CERT_ALTNAME_INVALID", "Hostname/IP does not match"), + /different hostname/i, + ], + [ + "SELF_SIGNED_CERT_IN_CHAIN", + fetchError( + "SELF_SIGNED_CERT_IN_CHAIN", + "self-signed certificate in certificate chain", + ), + /does not trust the TLS certificate/i, + ], + ]; + + test.each( + CASES, + )("%s produces a complete diagnosis", (_name, err, matcher) => { + const d = diagnoseError(err, { + url: "https://api.example.com/x", + method: "GET", + timeoutMs: 20_000, + }); + expect(d.what).toMatch(matcher); + // All four parts must be populated — a blank field is a silent regression + // back towards "fetch failed". + expect(d.what.length).toBeGreaterThan(10); + expect(d.cause.length).toBeGreaterThan(10); + expect(d.fix.length).toBeGreaterThan(10); + expect(d.context.length).toBeGreaterThan(0); + expect(d.raw.length).toBeGreaterThan(0); + }); + + // The guard that gives the whole feature its point. + test.each(CASES)("%s never renders a bare `fetch failed`", (_n, err) => { + const d = diagnoseError(err, { url: "https://api.example.com/x" }); + expect(d.what).not.toBe("fetch failed"); + expect(d.what).not.toMatch(/^fetch failed/); + // The raw chain may (and should) still contain it verbatim — that's the + // point of the raw section — but never the explanation. + expect(d.raw.join("\n")).toContain("fetch failed"); + }); + + test("preserves the full cause chain verbatim, with codes and syscalls", () => { + const d = diagnoseError( + fetchError("ENOTFOUND", "getaddrinfo ENOTFOUND api.example.com", { + syscall: "getaddrinfo", + hostname: "api.example.com", + }), + ); + const raw = d.raw.join("\n"); + expect(raw).toContain("TypeError: fetch failed"); + expect(raw).toContain("getaddrinfo ENOTFOUND api.example.com"); + expect(raw).toContain("code ENOTFOUND"); + expect(raw).toContain("syscall getaddrinfo"); + }); + + // Node emits one entry per address family when a host has both A and AAAA + // records, and the real reason lives inside the AggregateError. + test("unwraps an AggregateError from a dual-stack connect", () => { + const outer = new TypeError("fetch failed"); + const agg = new AggregateError( + [ + Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:9"), { + code: "ECONNREFUSED", + address: "127.0.0.1", + port: 9, + }), + Object.assign(new Error("connect ECONNREFUSED ::1:9"), { + code: "ECONNREFUSED", + }), + ], + "", + ); + (outer as Error & { cause?: unknown }).cause = agg; + + const d = diagnoseError(outer); + expect(d.what).toMatch(/connection refused/i); + expect(d.what).toContain("127.0.0.1:9"); + }); + + // AbortSignal.timeout's own message names neither host nor duration. + test("a real AbortSignal.timeout names the URL and the elapsed budget", async () => { + const signal = AbortSignal.timeout(1); + const err = await new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(signal.reason)); + }); + const d = diagnoseError(err, { + url: "https://api.example.com/x", + timeoutMs: 20_000, + }); + expect(d.what).toMatch(/no response/i); + expect(d.what).toContain("api.example.com"); + expect(d.what).toContain("20s"); + }); + + // Code that catches and re-throws routinely drops .code but keeps the text. + test("recovers the code and host from the message when properties are lost", () => { + const outer = new TypeError("fetch failed"); + (outer as Error & { cause?: unknown }).cause = new Error( + "getaddrinfo ENOTFOUND sso.example.com", + ); + const d = diagnoseError(outer); + expect(d.what).toMatch(/could not resolve/i); + expect(d.what).toContain("sso.example.com"); + }); + + test("an unknown code still explains itself rather than echoing the error", () => { + const d = diagnoseError(fetchError("E_WEIRD", "something odd happened"), { + url: "https://api.example.com/x", + }); + expect(d.what).toContain("something odd happened"); + expect(d.cause.length).toBeGreaterThan(10); + expect(d.fix.length).toBeGreaterThan(10); + }); + + describe("proxy state shapes the explanation", () => { + test("no proxy set → tells the user to configure one", () => { + const d = diagnoseError( + fetchError("ENOTFOUND", "getaddrinfo ENOTFOUND api.example.com"), + ); + expect(d.fix).toContain("HTTP_PROXY"); + expect(rendered(d)).toContain("proxy: none"); + }); + + // The single most common silent failure: a proxy is set and Node ignores + // it. That must dominate the explanation, because nothing else can be + // diagnosed until it is fixed. + test("proxy set but NODE_USE_ENV_PROXY unset → that is the fix", () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + const d = diagnoseError( + fetchError("ENOTFOUND", "getaddrinfo ENOTFOUND api.example.com"), + ); + expect(d.fix).toContain("NODE_USE_ENV_PROXY=1"); + expect(d.cause).toContain("IGNORING your proxy settings"); + expect(rendered(d)).toContain("NODE_USE_ENV_PROXY: unset"); + }); + + test("proxy active → ECONNREFUSED is read as a wrong proxy address", () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + const d = diagnoseError( + fetchError("ECONNREFUSED", "connect ECONNREFUSED 10.0.0.1:8080", { + address: "10.0.0.1", + port: 8080, + }), + ); + expect(d.cause).toMatch(/that address is your proxy/i); + expect(d.fix).toContain("HTTP_PROXY"); + }); + + test("a NO_PROXY exemption for the backend is reported in the context", () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + vi.stubEnv("NO_PROXY", "*.viettel.vn"); + const d = diagnoseError(fetchError("ETIMEDOUT", "connect ETIMEDOUT")); + expect(rendered(d)).toContain("*.viettel.vn"); + }); + }); + + test("redacts credentials that appear in an error message", () => { + const d = diagnoseError( + fetchError( + "E_WEIRD", + "rejected request with Authorization: Bearer abc123def456ghi", + ), + ); + const all = rendered(d); + expect(all).not.toContain("abc123def456ghi"); + expect(all).toContain("[REDACTED]"); + }); +}); + +describe("diagnoseResponse", () => { + function headers(init: Record = {}): Headers { + return new Headers(init); + } + + test("407 explains proxy authentication and how to supply credentials", () => { + const d = diagnoseResponse( + 407, + "Proxy Authentication Required", + headers({ "proxy-authenticate": "Basic realm=corp" }), + "", + { url: "https://api.example.com/x" }, + ); + expect(d.what).toContain("407"); + expect(d.fix).toContain("user:password@host:port"); + expect(d.raw.join("\n")).toContain("Basic realm=corp"); + }); + + // The documented npm-403 case: it reads as a permissions problem but is the + // network blocking the request. + test("403 with proxy signatures is attributed to the network, not the account", () => { + const d = diagnoseResponse( + 403, + "Forbidden", + headers({ via: "1.1 corp-proxy" }), + "blocked by policy", + { url: "https://registry.npmjs.org/x" }, + ); + expect(d.cause).toMatch(/network blocking/i); + expect(d.fix).toContain("unset"); + }); + + test("502 is attributed to the proxy rather than a CoDev outage", () => { + const d = diagnoseResponse(502, "Bad Gateway", headers(), "", { + url: "https://api.example.com/x", + }); + expect(d.cause).toMatch(/proxy failing to reach/i); + }); + + test("truncates a huge error body so it can't blow up the frame", () => { + const d = diagnoseResponse( + 500, + "Server Error", + headers(), + "x".repeat(5000), + ); + expect(d.raw.join("\n").length).toBeLessThan(1000); + }); + + test("redacts a bearer token echoed back in an error body", () => { + const d = diagnoseResponse( + 401, + "Unauthorized", + headers(), + '{"error":"bad token: Bearer supersecrettokenvalue"}', + ); + const all = d.raw.join("\n"); + expect(all).not.toContain("supersecrettokenvalue"); + expect(all).toContain("[REDACTED]"); + }); +}); + +describe("diagnoseExec", () => { + test("ENOENT is reported as a missing program, not a failed command", () => { + const d = diagnoseExec("npm -v", "ENOENT", "", true); + expect(d.what).toContain("not found on your PATH"); + expect(d.fix).toContain("nodejs.org"); + }); + + // npm's stderr names the registry, proxy and .npmrc in play — truncating it + // destroys the diagnosis, so it must survive in full. + test("keeps npm's stderr in full", () => { + const stderr = Array.from( + { length: 60 }, + (_, i) => `npm ERR! line ${i}`, + ).join("\n"); + const d = diagnoseExec("npm ping", 1, stderr, false); + expect(d.raw).toHaveLength(60); + expect(d.raw.at(-1)).toContain("line 59"); + }); + + test("E403 gets the documented unset-proxy-in-this-shell fix", () => { + const d = diagnoseExec("npm ping", 1, "npm ERR! code E403", false); + expect(d.fix).toContain("unset HTTPS_PROXY HTTP_PROXY"); + expect(d.fix).toContain("$env:HTTPS_PROXY = $null"); + }); + + test("EACCES points at a writable prefix, not sudo", () => { + const d = diagnoseExec("npm i -g x", 1, "npm ERR! EACCES denied", false); + expect(d.fix).toContain("npm config set prefix"); + expect(d.fix).toMatch(/avoid `sudo npm i -g`/i); + }); + + test("a TLS failure points at npm's own cafile, not Node's env vars", () => { + const d = diagnoseExec( + "npm ping", + 1, + "npm ERR! request to https://registry.npmjs.org failed, reason: self-signed certificate in certificate chain", + false, + ); + expect(d.fix).toContain("npm config set cafile"); + }); + + test("a network failure names the internal registry mirror", () => { + const d = diagnoseExec("npm ping", 1, "npm ERR! ETIMEDOUT", false); + expect(d.fix).toContain(INTERNAL_NPM_REGISTRY); + }); +}); + +describe("describeFailure", () => { + test("upgrades a transport error to the full diagnosis", () => { + const msg = describeFailure( + fetchError("ENOTFOUND", "getaddrinfo ENOTFOUND api.example.com"), + ); + expect(msg).toMatch(/could not resolve/i); + expect(msg).toContain("Fix:"); + }); + + // A backend HTTP error already has a precise message; wrapping it in + // proxy-oriented reasoning would be actively misleading. + test("leaves a plain backend error message alone", () => { + const err = new Error("Backend /config failed (403): forbidden"); + expect(isTransportError(err)).toBe(false); + expect(describeFailure(err)).toBe( + "Backend /config failed (403): forbidden", + ); + }); +}); + +describe("environment checks", () => { + test("the pre-flight subset is pure — no subprocess, no OS trust store", async () => { + const exec = vi.spyOn(npm, "execAsync"); + const ca = vi.spyOn(tls.tlsApi, "getCACertificates"); + await runChecks(PREFLIGHT_CHECKS, {}); + // Both are load-bearing: `npm config get` costs ~300ms apiece, and the + // OS-store read blocks the event loop for 300ms+ on Windows, which is + // exactly what stalls Ink's render timers (see lib/tls.ts). + expect(exec).not.toHaveBeenCalled(); + expect(ca).not.toHaveBeenCalled(); + }); + + test("the pre-flight subset is a subset of the full environment group", () => { + for (const check of PREFLIGHT_CHECKS) { + expect(ENVIRONMENT_CHECKS).toContain(check); + } + }); + + async function runOne(key: string): Promise { + const check = ENVIRONMENT_CHECKS.find((c) => c.key === key); + if (!check) throw new Error(`no such check: ${key}`); + const [outcome] = await runChecks([check], {}); + if (!outcome) throw new Error("no outcome"); + return outcome; + } + + test("node-version passes on the Node running the suite", async () => { + // The suite can only run on a supported Node, so this pins that the + // check agrees with the startup gate rather than drifting from it. + expect((await runOne("node-version")).status).toBe("pass"); + }); + + test("proxy-env warns when a proxy is set without NODE_USE_ENV_PROXY", async () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + const o = await runOne("proxy-env"); + expect(o.status).toBe("warn"); + expect(o.fix).toContain("NODE_USE_ENV_PROXY"); + }); + + test("proxy-env warns about a NO_PROXY entry covering the backend", async () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + vi.stubEnv("NODE_USE_SYSTEM_CA", "1"); + vi.stubEnv("NO_PROXY", "*.viettel.vn"); + const o = await runOne("proxy-env"); + expect(o.status).toBe("warn"); + expect(o.fix).toContain("*.viettel.vn"); + expect(o.fix).toContain("Login failed"); + }); + + // Accuracy matters more than brevity here: claiming Node ignores the proxy, + // on a run where CoDev enabled it, contradicts the evidence in the same + // report. The advice must shift to "set it for npm and the agents". + test("proxy-env explains an auto-enabled proxy without claiming it is ignored", async () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + vi.stubEnv("NODE_USE_SYSTEM_CA", "1"); + vi.spyOn(proxy, "proxyAutoEnabled").mockReturnValue(true); + const o = await runOne("proxy-env"); + expect(o.status).toBe("warn"); + expect(o.fix).toContain("CoDev enabled proxy support for this run"); + expect(o.fix).not.toContain("Node ignores proxy environment variables"); + }); + + test("proxy-env flags disabled TLS verification", async () => { + vi.stubEnv("NODE_TLS_REJECT_UNAUTHORIZED", "0"); + const o = await runOne("proxy-env"); + expect(o.status).toBe("warn"); + expect(o.fix).toContain("NODE_TLS_REJECT_UNAUTHORIZED=0"); + }); + + test("proxy-env passes on a clean environment", async () => { + expect((await runOne("proxy-env")).status).toBe("pass"); + }); + + test("npm-available fails with an install hint when npm is missing", async () => { + vi.spyOn(npm, "execAsync").mockResolvedValue({ + stdout: "", + stderr: "", + error: Object.assign(new Error("spawn npm ENOENT"), { code: "ENOENT" }), + }); + const o = await runOne("npm-available"); + expect(o.status).toBe("fail"); + expect(o.diagnosis?.what).toContain("not found on your PATH"); + }); + + test("npm-registry warns when the shell has a proxy but npm does not", async () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + vi.spyOn(npm, "execAsync").mockImplementation(async (_f, args) => ({ + stdout: args[2] === "registry" ? INTERNAL_NPM_REGISTRY : "undefined", + stderr: "", + error: null, + })); + const o = await runOne("npm-registry"); + expect(o.status).toBe("warn"); + expect(o.fix).toContain("npm config set proxy"); + }); + + test("npm-registry warns when pointed at the public registry", async () => { + vi.spyOn(npm, "execAsync").mockImplementation(async (_f, args) => ({ + stdout: + args[2] === "registry" ? "https://registry.npmjs.org/" : "undefined", + stderr: "", + error: null, + })); + const o = await runOne("npm-registry"); + expect(o.status).toBe("warn"); + expect(o.fix).toContain(INTERNAL_NPM_REGISTRY); + }); + + test("system-ca warns on a Node that cannot read the OS store", async () => { + vi.spyOn(tls.tlsApi, "supported").mockReturnValue(false); + const o = await runOne("system-ca"); + expect(o.status).toBe("warn"); + expect(o.fix).toContain("NODE_EXTRA_CA_CERTS"); + }); + + test("system-ca warns when the store is empty", async () => { + vi.spyOn(tls.tlsApi, "supported").mockReturnValue(true); + vi.spyOn(tls.tlsApi, "getCACertificates").mockReturnValue([]); + expect((await runOne("system-ca")).status).toBe("warn"); + }); +}); + +describe("llm checks", () => { + async function runLlm(key: string, ctx: object): Promise { + const check = LLM_CHECKS.find((c) => c.key === key); + if (!check) throw new Error(`no such check: ${key}`); + const [outcome] = await runChecks([check], ctx); + if (!outcome) throw new Error("no outcome"); + return outcome; + } + + test("skips cleanly when there is no API key", async () => { + const o = await runLlm("gateway-key", {}); + expect(o.status).toBe("skip"); + }); + + test("a rejected key fails with a re-auth instruction", async () => { + vi.spyOn(backend, "validateApiKey").mockResolvedValue(false); + const o = await runLlm("gateway-key", { apiKey: "k" }); + expect(o.status).toBe("fail"); + expect(o.fix).toContain("codevhub login --force"); + }); + + // The only check that proves inference is actually permitted — /key/info + // and /v1/models both pass for a key that is 403'd on every completion. + test("a gateway completion refusal fails with the reason attached", async () => { + vi.spyOn(backend, "smokeTestModel").mockResolvedValue( + "Gateway rejected a test request for m-alpha (HTTP 403): budget exceeded", + ); + const o = await runLlm("llm-completion", { + apiKey: "k", + models: ["m-alpha"], + }); + expect(o.status).toBe("fail"); + expect(o.diagnosis?.raw.join("\n")).toContain("budget exceeded"); + expect(o.diagnosis?.cause).toMatch(/only proves the key exists/i); + }); + + test("a successful completion passes and names the model", async () => { + vi.spyOn(backend, "smokeTestModel").mockResolvedValue(null); + const o = await runLlm("llm-completion", { + apiKey: "k", + models: ["m-alpha"], + }); + expect(o.status).toBe("pass"); + expect(o.detail).toContain("m-alpha"); + }); +}); + +describe("runChecks", () => { + test("a check that throws becomes a failure rather than taking the run down", async () => { + const outcomes = await runChecks( + [ + { + key: "boom", + label: "Boom", + group: "environment", + run: async () => { + throw new Error("unexpected"); + }, + }, + ], + {}, + ); + expect(outcomes[0]?.status).toBe("fail"); + expect(hasFailure(outcomes)).toBe(true); + }); + + test("threads context forward between checks", async () => { + const ctx: { apiKey?: string } = {}; + await runChecks( + [ + { + key: "a", + label: "A", + group: "account", + run: async (c) => { + c.apiKey = "from-a"; + return { status: "pass", detail: "" }; + }, + }, + { + key: "b", + label: "B", + group: "llm", + run: async (c) => ({ status: "pass", detail: c.apiKey ?? "missing" }), + }, + ], + ctx, + ); + expect(ctx.apiKey).toBe("from-a"); + }); +}); + +describe("remediation output", () => { + const failing: CheckOutcome[] = [ + { + key: "proxy-env", + label: "Proxy & TLS environment", + group: "environment", + status: "warn", + detail: "…", + fix: "Set NODE_USE_ENV_PROXY=1.", + }, + { + key: "backend-reach", + label: "Reach the CoDev backend", + group: "network", + status: "fail", + detail: "…", + fix: "Check the proxy address.", + }, + ]; + + test("lists every actionable item and ends pointing at install", () => { + const lines = buildNextSteps(failing).join("\n"); + expect(lines).toContain("Set NODE_USE_ENV_PROXY=1."); + expect(lines).toContain("Check the proxy address."); + expect(lines).toContain("codevhub install"); + }); + + // The explicit requirement: users must leave with instructions they can + // apply before running install. + test("includes persistent setup instructions when the failure is proxy-related", () => { + const lines = buildNextSteps(failing, { + http: "http://10.0.0.1:8080", + https: "http://10.0.0.1:8080", + }).join("\n"); + expect(lines).toContain("NODE_USE_ENV_PROXY"); + expect(lines).toContain("10.0.0.1:8080"); + expect(lines).toContain("npm config set proxy"); + }); + + test("says nothing when everything passed", () => { + expect( + buildNextSteps([ + { + key: "x", + label: "X", + group: "network", + status: "pass", + detail: "ok", + }, + ]), + ).toEqual([]); + }); + + test("persist instructions are platform-appropriate", () => { + const lines = persistProxyInstructions({ + https: "http://10.0.0.1:8080", + }).join("\n"); + if (process.platform === "win32") { + expect(lines).toContain("SetEnvironmentVariable"); + } else { + expect(lines).toContain("export HTTPS_PROXY="); + } + // npm's proxy config is separate from the shell's, and users miss this. + expect(lines).toContain("npm config set https-proxy"); + }); +}); + +describe("normalizeProxyInput", () => { + test.each([ + ["10.0.0.1:8080", "http://10.0.0.1:8080"], + ["http://10.0.0.1:8080", "http://10.0.0.1:8080"], + ["https://proxy.corp:3128", "https://proxy.corp:3128"], + [" 10.0.0.1:8080 ", "http://10.0.0.1:8080"], + ])("%s → %s", (input, expected) => { + expect(normalizeProxyInput(input)).toBe(expected); + }); + + test.each(["", " ", "::::"])("rejects %s", (input) => { + expect(normalizeProxyInput(input)).toBeNull(); + }); +}); + +describe("renderDiagnosisCompact", () => { + test("keeps the explanation and the fix", () => { + const out = renderDiagnosisCompact({ + what: "W", + cause: "C", + fix: "F", + context: ["ctx"], + raw: ["raw"], + }); + expect(out).toBe("W\nC\nFix: F"); + }); +}); diff --git a/tests/lib/proxy.test.ts b/tests/lib/proxy.test.ts new file mode 100644 index 0000000..e165c76 --- /dev/null +++ b/tests/lib/proxy.test.ts @@ -0,0 +1,222 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { BACKEND_URL } from "@/lib/const.js"; +import { + applyEnvProxy, + backendHost, + hasProxyConfigured, + httpApi, + matchingNoProxyEntry, + noProxyEntryMatches, + PROXY_APPLIED_ENV, + proxyAutoEnabled, + readProxyEnv, + resetProxyState, + stripNoProxyFor, +} from "@/lib/proxy.js"; +import { spawner } from "@/lib/reexec.js"; + +// Every proxy variable Node or we might read. Cleared before each test so a +// developer's own shell can't leak into the assertions. +const PROXY_VARS = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "NO_PROXY", + "no_proxy", + "NODE_USE_ENV_PROXY", + "NODE_USE_SYSTEM_CA", + "NODE_TLS_REJECT_UNAUTHORIZED", + PROXY_APPLIED_ENV, +]; + +beforeEach(() => { + for (const name of PROXY_VARS) vi.stubEnv(name, ""); + resetProxyState(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +describe("readProxyEnv", () => { + test("reads both upper and lower case spellings", () => { + vi.stubEnv("http_proxy", "http://lower:1"); + vi.stubEnv("HTTPS_PROXY", "http://upper:2"); + const env = readProxyEnv(); + expect(env.httpProxy).toBe("http://lower:1"); + expect(env.httpsProxy).toBe("http://upper:2"); + expect(hasProxyConfigured(env)).toBe(true); + }); + + test("treats an empty string as unset", () => { + vi.stubEnv("HTTP_PROXY", " "); + expect(hasProxyConfigured(readProxyEnv())).toBe(false); + }); + + test("NODE_USE_ENV_PROXY=0 counts as off", () => { + vi.stubEnv("NODE_USE_ENV_PROXY", "0"); + expect(readProxyEnv().useEnvProxy).toBe(false); + }); + + test("reports NODE_TLS_REJECT_UNAUTHORIZED verbatim so 0 can be flagged", () => { + vi.stubEnv("NODE_TLS_REJECT_UNAUTHORIZED", "0"); + expect(readProxyEnv().tlsRejectUnauthorized).toBe("0"); + }); +}); + +describe("NO_PROXY matching", () => { + test.each([ + ["*.viettel.vn", "netmind.viettel.vn", true], + [".viettel.vn", "netmind.viettel.vn", true], + ["viettel.vn", "netmind.viettel.vn", true], + ["viettel.vn", "viettel.vn", true], + ["*", "anything.example.com", true], + ["netmind.viettel.vn:443", "netmind.viettel.vn", true], + ["other.vn", "netmind.viettel.vn", false], + // A suffix that is not on a label boundary must not match. + ["ettel.vn", "netmind.viettel.vn", false], + ["", "netmind.viettel.vn", false], + ])("%s vs %s → %s", (entry, host, expected) => { + expect(noProxyEntryMatches(entry, host)).toBe(expected); + }); + + // The documented cause of "Login failed" on internal machines: images ship + // with *.viettel.vn in NO_PROXY, which routes our backend traffic direct. + test("finds the entry that exempts the CoDev backend", () => { + vi.stubEnv("NO_PROXY", "localhost,127.0.0.1,*.viettel.vn"); + expect(matchingNoProxyEntry(readProxyEnv(), backendHost())).toBe( + "*.viettel.vn", + ); + }); + + test("returns null when nothing exempts the backend", () => { + vi.stubEnv("NO_PROXY", "localhost,127.0.0.1"); + expect(matchingNoProxyEntry(readProxyEnv(), backendHost())).toBeNull(); + }); + + test("stripNoProxyFor removes only the offending entries", () => { + expect( + stripNoProxyFor("localhost, *.viettel.vn ,127.0.0.1", backendHost()), + ).toBe("localhost,127.0.0.1"); + }); + + test("backendHost matches the configured backend URL", () => { + expect(backendHost()).toBe(new URL(BACKEND_URL).hostname); + }); +}); + +describe("applyEnvProxy", () => { + test("no-ops when no proxy is configured", () => { + const spy = vi.spyOn(httpApi, "setGlobalProxyFromEnv"); + expect(applyEnvProxy().action).toBe("none"); + expect(spy).not.toHaveBeenCalled(); + }); + + test("does nothing when the user already set NODE_USE_ENV_PROXY", () => { + vi.stubEnv("HTTPS_PROXY", "http://p:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + const spy = vi.spyOn(httpApi, "setGlobalProxyFromEnv"); + expect(applyEnvProxy().action).toBe("already-active"); + expect(spy).not.toHaveBeenCalled(); + }); + + test("enables the proxy in-process when Node supports it", () => { + vi.stubEnv("HTTPS_PROXY", "http://p:8080"); + vi.spyOn(httpApi, "supported").mockReturnValue(true); + const apply = vi + .spyOn(httpApi, "setGlobalProxyFromEnv") + .mockImplementation(() => {}); + const spawn = vi.spyOn(spawner, "spawnSync"); + + expect(applyEnvProxy().action).toBe("applied"); + expect(apply).toHaveBeenCalledOnce(); + // The whole point of the fast path: no extra process. + expect(spawn).not.toHaveBeenCalled(); + // The environment must reflect reality afterwards. Without this, + // diagnoseError would report "Node is IGNORING your proxy settings" about + // a request that had just gone through the proxy — and children we spawn + // (npm, the agents) would not inherit it. + expect(process.env.NODE_USE_ENV_PROXY).toBe("1"); + expect(readProxyEnv().useEnvProxy).toBe(true); + expect(proxyAutoEnabled()).toBe(true); + }); + + test("a user-set NODE_USE_ENV_PROXY is not reported as auto-enabled", () => { + vi.stubEnv("HTTPS_PROXY", "http://p:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + expect(applyEnvProxy().action).toBe("already-active"); + // The distinction drives the advice: only the auto path needs to tell the + // user to set it in their shell for npm and the agents. + expect(proxyAutoEnabled()).toBe(false); + }); + + test("falls back to a re-exec on a Node without setGlobalProxyFromEnv", () => { + vi.stubEnv("HTTPS_PROXY", "http://p:8080"); + vi.spyOn(httpApi, "supported").mockReturnValue(false); + vi.spyOn(process.stderr, "write").mockReturnValue(true); + const spawn = vi + .spyOn(spawner, "spawnSync") + // biome-ignore lint/suspicious/noExplicitAny: minimal SpawnSyncReturns stub + .mockReturnValue({ status: 0 } as any); + + const result = applyEnvProxy(); + expect(result.action).toBe("reexec"); + expect(result.exitCode).toBe(0); + + // The child must carry the flag Node only reads at bootstrap, plus the + // sentinel that stops it looping if Node still ignores it. + const env = spawn.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv; + expect(env.NODE_USE_ENV_PROXY).toBe("1"); + expect(env[PROXY_APPLIED_ENV]).toBe("1"); + }); + + test("the sentinel stops a second attempt in the re-exec child", () => { + vi.stubEnv("HTTPS_PROXY", "http://p:8080"); + vi.stubEnv(PROXY_APPLIED_ENV, "1"); + vi.spyOn(httpApi, "supported").mockReturnValue(false); + const spawn = vi.spyOn(spawner, "spawnSync"); + + expect(applyEnvProxy().action).toBe("already-active"); + expect(spawn).not.toHaveBeenCalled(); + }); + + test("falls back to a re-exec when setGlobalProxyFromEnv throws", () => { + vi.stubEnv("HTTPS_PROXY", "http://p:8080"); + vi.spyOn(httpApi, "supported").mockReturnValue(true); + vi.spyOn(httpApi, "setGlobalProxyFromEnv").mockImplementation(() => { + throw new Error("nope"); + }); + vi.spyOn(process.stderr, "write").mockReturnValue(true); + const spawn = vi + .spyOn(spawner, "spawnSync") + // biome-ignore lint/suspicious/noExplicitAny: minimal SpawnSyncReturns stub + .mockReturnValue({ status: 3 } as any); + + const result = applyEnvProxy(); + expect(result.action).toBe("reexec"); + expect(result.exitCode).toBe(3); + expect(spawn).toHaveBeenCalledOnce(); + }); + + // The warning has to survive every branch: a NO_PROXY exemption defeats the + // proxy no matter how (or whether) it was enabled. + test("warns about a backend NO_PROXY exemption even with no proxy set", () => { + vi.stubEnv("NO_PROXY", "*.viettel.vn"); + const result = applyEnvProxy(); + expect(result.action).toBe("none"); + expect(result.noProxyWarning).toContain("*.viettel.vn"); + expect(result.noProxyWarning).toContain("Login failed"); + }); + + test("warns about a backend NO_PROXY exemption on the applied path", () => { + vi.stubEnv("HTTPS_PROXY", "http://p:8080"); + vi.stubEnv("NO_PROXY", "*.viettel.vn"); + vi.spyOn(httpApi, "supported").mockReturnValue(true); + vi.spyOn(httpApi, "setGlobalProxyFromEnv").mockImplementation(() => {}); + const result = applyEnvProxy(); + expect(result.action).toBe("applied"); + expect(result.noProxyWarning).toContain("*.viettel.vn"); + }); +}); From e423b516ef0c030387c180458477b8bae9e434f9 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Tue, 28 Jul 2026 15:57:07 +0700 Subject: [PATCH 02/16] docs: trim the Node-floor rationale and drop the doctor section from the npm README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "22.21 is the release that added HTTP_PROXY support" explanation was detail readers don't need at the top of a README; the requirement itself is enough. The rationale is still recorded in AGENTS.md and in the startup error message, where it's actionable. The npm package page also no longer carries the full `codevhub doctor` walkthrough — the repo README keeps it. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 4 +--- README.npm.md | 34 +--------------------------------- 2 files changed, 2 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 0c97aa2..b01ffd3 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,7 @@ CoDev — AI Coding Agent Hub. Install, configure, and manage multiple AI coding agents. -Requires Node.js ≥ 22.21 (Node 24+ recommended). 22.21 is the release that added -`HTTP_PROXY`/`HTTPS_PROXY` support to the Node 22 line — below it Node silently -ignores proxy settings, so sign-in cannot work behind a corporate proxy. +Requires Node.js ≥ 22.21 (Node 24+ recommended). ## Install diff --git a/README.npm.md b/README.npm.md index c74b87a..0143467 100644 --- a/README.npm.md +++ b/README.npm.md @@ -2,9 +2,7 @@ CoDev — AI Coding Agent Hub. Install, configure, and manage multiple AI coding agents. -Requires Node.js ≥ 22.21 (Node 24+ recommended). 22.21 is the release that added -`HTTP_PROXY`/`HTTPS_PROXY` support to the Node 22 line — below it Node silently -ignores proxy settings, so sign-in cannot work behind a corporate proxy. +Requires Node.js ≥ 22.21 (Node 24+ recommended). ## Install @@ -21,36 +19,6 @@ codevhub install After install, go to your project and type `codev`, `claude`, `codex`, or `opencode` to launch. -## Check your setup first: `codevhub doctor` - -On a corporate network — behind a proxy, a TLS-inspecting gateway, or an -internal npm mirror — run this before `codevhub install`: - -```bash -codevhub doctor -``` - -It is read-only (it installs and configures nothing) and checks, in order: - -- **Environment** — Node version, npm, the global npm prefix (on `PATH` and - writable), the npm registry/proxy configuration, your proxy and TLS - environment variables, and whether the OS certificate store is readable. -- **Network** — the CoDev backend and the npm registry are actually reachable. -- **Account** — sign-in, gateway API key, CoDev configuration, and Supabase - (used by `codevhub upload`). -- **LLM access** — the key is valid, models are listable, and a real one-token - completion succeeds. Only the last of these proves inference is permitted. -- **This machine** — what is already installed, configured, and backed up. - -When something fails it prints what happened, the most likely cause given your -proxy and TLS settings, the fix, and the raw error — never a bare -`fetch failed`. If the network checks fail it offers to re-run everything -through a proxy you type in, then prints the exact `export` / `setx` commands to -make the working settings permanent. - -Use `codevhub doctor --force` to test a real sign-in instead of reusing a cached -session. Exit code is 0 when nothing failed (warnings do not fail it), 1 otherwise. - ## CodeGraph integration When you run `codevhub install` or `codevhub config`, CoDev also installs [CodeGraph](https://github.com/colbymchenry/codegraph) — a local, MCP-based, pre-indexed code-knowledge-graph tool — and wires it into each agent you select (Claude Code, Codex, OpenCode, etc.). Because your codebase is pre-indexed, agents can look up symbols, references, and structure straight from the graph instead of repeatedly grepping and reading files to find their way around. That means **fewer tool calls and fewer tokens** per task — the agent spends its context on the work rather than on rediscovering the codebase. From 73573335ab7b09f7d2229c626e3494ed47500c61 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Tue, 28 Jul 2026 16:09:17 +0700 Subject: [PATCH 03/16] fix: stop `doctor` reporting a healthy backend as unreachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `codevhub doctor` failed the backend reachability check with a connect timeout on machines where `codevhub install` worked perfectly. Two causes, both in the probe rather than the network. It probed `GET /codev-backend` — the API base path, which is not an endpoint the CLI ever calls. The gateway 301s it to its own internal origin (`http://netmind.viettel.vn:9096/codev-backend/` — note the port and the downgrade to http), `fetch` followed that redirect by default, and the connect to :9096 times out from outside the corporate network. So the probe measured an address no CoDev command touches and blamed the backend. Now it probes `POST /codev-backend/config`, a route the CLI actually calls, which answers 401 without a token — proving DNS, TCP, TLS and the real API route in one round trip. `reach()` also sets `redirect: "manual"`: a redirect response is already proof the transport works, and chasing it measures a different host and port. A 401/403 is labelled as expected for an unauthenticated probe so it doesn't read like a failure. Two related false diagnoses found while verifying the fix against the real backend: `diagnoseError` blamed the proxy for errors that never reached the network. `Backend /auth/exchange failed (401)` has no `err.cause` and no errno, so it fell through to the generic branch and told users with demonstrably working networks — the server had just answered them — that they were missing a proxy. Application-level errors now report the server's own message, and an auth status points at `codevhub login --force`. Transport errors keep their proxy reasoning, including causeless ones whose errno survives only in the message text. And the proxy setup block was appended whenever anything in the account or LLM group failed, burying "log in again" under a wall of export lines. It now keys off the diagnosis naming the proxy environment variables. Matching the variable names rather than the word "proxy" matters: the internal npm mirror's URL ends in `/npm-proxy`, which a loose match pulled the block in on a run whose only issue was the registry setting. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/doctor.ts | 77 +++++++++++++++--- tests/lib/doctor.test.ts | 172 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+), 10 deletions(-) diff --git a/src/lib/doctor.ts b/src/lib/doctor.ts index 499dd44..c4f9831 100644 --- a/src/lib/doctor.ts +++ b/src/lib/doctor.ts @@ -451,6 +451,30 @@ export function diagnoseError(err: unknown, attempt: Attempt = {}): Diagnosis { ); } + // Nothing in the chain names a network failure and there is no `cause`, so + // this is our own code reporting what the server said — a thrown + // `Backend /auth/exchange failed (401): …`, not a connectivity problem. + // Running it through the proxy reasoning below would tell a user whose + // network is fine that they are missing a proxy, which is worse than + // saying nothing. `base()` is deliberately not used here: its whole job is + // to fold in proxy speculation. + if (root?.code === undefined && !isTransportError(err)) { + const message = root?.message ?? String(err); + const status = /\((\d{3})\)/.exec(message)?.[1]; + const isAuth = status === "401" || status === "403"; + return { + what: redactSecrets(message), + cause: isAuth + ? `${host} answered and rejected the credentials — an authorization result, not a connectivity problem.` + : `${host} answered; it rejected the request rather than the network failing.`, + fix: isAuth + ? "Run `codevhub login --force` to re-authenticate, then re-run." + : "See the raw output below — the server's own message is the best guide here.", + context, + raw, + }; + } + // Unknown code — still never a bare "fetch failed". Name the deepest message // we found and hand over the raw chain. return base( @@ -697,15 +721,28 @@ async function guard( } /** - * Transport-only reachability probe. ANY HTTP response — including 401/403/404 - * — proves the network path works, which is all this is asking. Only DNS/TCP/ - * TLS failures fail it. + * Transport-only reachability probe. ANY HTTP response — including 3xx and + * 401/403/404 — proves the network path works, which is all this is asking. + * Only DNS/TCP/TLS failures fail it. + * + * `redirect: "manual"` is load-bearing, not tidiness. `fetch` follows redirects + * by default, and the gateway 301s bare directory-ish paths to its *internal* + * origin (`GET /codev-backend` → `http://netmind.viettel.vn:9096/codev-backend/`, + * note the port and the downgrade to http). Following that from outside the + * corporate network times out, so the probe reported the backend unreachable + * on machines where every real API call worked. A redirect response is already + * proof the transport works; chasing it measures a different host and port. */ -async function reach(url: string, label: string): Promise { - const attempt = { url, method: "GET", timeoutMs: REACH_TIMEOUT_MS }; +async function reach( + url: string, + label: string, + method = "GET", +): Promise { + const attempt = { url, method, timeoutMs: REACH_TIMEOUT_MS }; return guard(attempt, async () => { const res = await loggedFetch("doctor.reach", url, { - method: "GET", + method, + redirect: "manual", signal: AbortSignal.timeout(REACH_TIMEOUT_MS), }); // 407 is the one status that means the transport is NOT usable. @@ -724,9 +761,15 @@ async function reach(url: string, label: string): Promise { diagnosis, }; } + // An unauthenticated probe is *supposed* to be rejected; saying so keeps + // a 401 from reading like a failure in a report full of real ones. + const note = + res.status === 401 || res.status === 403 + ? " — expected without credentials" + : ""; return { status: "pass", - detail: `${label} reachable (HTTP ${res.status}).`, + detail: `${label} reachable (HTTP ${res.status}${note}).`, }; }); } @@ -1030,11 +1073,16 @@ export const ENVIRONMENT_CHECKS: Check[] = [ // Group 2 — network (transport only, no auth) // --------------------------------------------------------------------------- +// Probe a route the CLI actually calls, not the API base path. `/codev-backend` +// on its own is not an endpoint — the gateway 301s it to an internal-only +// origin, so it measured something no CoDev command ever does. `POST /config` +// answers 401 without a token, which proves DNS, TCP, TLS and the real API +// route in one round trip. const backendReachCheck: Check = { key: "backend-reach", label: "Reach the CoDev backend", group: "network", - run: () => reach(BACKEND_URL, backendHost()), + run: () => reach(`${BACKEND_URL}/config`, backendHost(), "POST"), }; const npmReachCheck: Check = { @@ -1502,13 +1550,22 @@ export function buildNextSteps( } } + // Only append the proxy setup block when something actually points at the + // network. Keying off the group alone treated a plain expired-token 401 as + // a proxy problem and buried the real instruction ("log in again") under a + // wall of export lines. A transport failure anywhere still qualifies — + // diagnoseError puts the proxy variables in its `fix`, so that is the + // signal rather than the group. + // Match the environment variable names, not the word "proxy" — the npm + // mirror's own URL ends in `/npm-proxy`, so a loose /proxy/i match pulled + // the whole block in on a run whose only issue was the registry setting. + const NAMES_PROXY_ENV = /\b(?:HTTPS?_PROXY|NODE_USE_ENV_PROXY)\b/; const proxyRelated = outcomes.some( (o) => o.status !== "pass" && (o.key === "proxy-env" || o.group === "network" || - o.group === "account" || - o.group === "llm"), + NAMES_PROXY_ENV.test(o.fix ?? "")), ); if (proxyRelated) { lines.push(""); diff --git a/tests/lib/doctor.test.ts b/tests/lib/doctor.test.ts index ac2880d..aa8c4a4 100644 --- a/tests/lib/doctor.test.ts +++ b/tests/lib/doctor.test.ts @@ -12,12 +12,14 @@ import { INTERNAL_NPM_REGISTRY, isTransportError, LLM_CHECKS, + NETWORK_CHECKS, normalizeProxyInput, PREFLIGHT_CHECKS, persistProxyInstructions, renderDiagnosisCompact, runChecks, } from "@/lib/doctor.js"; +import * as log from "@/lib/log.js"; import * as npm from "@/lib/npm.js"; import * as proxy from "@/lib/proxy.js"; import { PROXY_APPLIED_ENV } from "@/lib/proxy.js"; @@ -228,6 +230,51 @@ describe("diagnoseError", () => { expect(d.what).toContain("sso.example.com"); }); + // Regression: an HTTP error our own code threw has no `.cause` and no + // errno, so it fell through to the generic branch and was reported as + // "No proxy is configured. If this network requires one, that is the most + // likely cause." — telling a user whose network is demonstrably fine (the + // server just answered them) to configure a proxy. + describe("application-level errors are not blamed on the network", () => { + test("a backend 401 is reported as an auth result, not a proxy problem", () => { + const d = diagnoseError( + new Error( + "Backend /auth/exchange failed (401): Invalid or expired SSO token", + ), + { url: "https://api.example.com/auth/exchange", method: "POST" }, + ); + expect(d.what).toContain("Invalid or expired SSO token"); + expect(d.cause).toMatch(/rejected the credentials/i); + expect(d.fix).toContain("codevhub login --force"); + // The proxy must not appear anywhere in the explanation. + expect(d.cause).not.toMatch(/proxy/i); + expect(d.fix).not.toMatch(/proxy/i); + }); + + test("a non-auth backend error points at the server's own message", () => { + const d = diagnoseError( + new Error("Backend /config returned incomplete payload: {}"), + ); + expect(d.cause).toMatch(/answered/i); + expect(d.fix).not.toMatch(/HTTP_PROXY/); + }); + + // A proxy IS the likely cause when nothing answered, so that reasoning + // must survive for errors that really did come from the transport. + test("a transport error keeps its proxy reasoning", () => { + const d = diagnoseError( + fetchError("ENOTFOUND", "getaddrinfo ENOTFOUND api.example.com"), + ); + expect(d.fix).toContain("HTTP_PROXY"); + }); + + // An errno in the message with no `cause` is still a network failure. + test("a causeless error naming an errno is still diagnosed as network", () => { + const d = diagnoseError(new Error("connect ECONNREFUSED 10.0.0.1:8080")); + expect(d.what).toMatch(/connection refused/i); + }); + }); + test("an unknown code still explains itself rather than echoing the error", () => { const d = diagnoseError(fetchError("E_WEIRD", "something odd happened"), { url: "https://api.example.com/x", @@ -547,6 +594,77 @@ describe("environment checks", () => { }); }); +// Regression: the probe used to GET the API base path and let `fetch` follow +// redirects. The gateway 301s that path to its internal origin +// (`http://netmind.viettel.vn:9096/codev-backend/`), which is unreachable from +// outside the corporate network — so `doctor` reported the backend down on +// machines where `codevhub install` worked perfectly. +describe("backend reachability probe", () => { + async function runReach(): Promise { + const check = NETWORK_CHECKS.find((c) => c.key === "backend-reach"); + if (!check) throw new Error("no backend-reach check"); + const [outcome] = await runChecks([check], {}); + if (!outcome) throw new Error("no outcome"); + return outcome; + } + + test("never follows redirects, and hits a real route rather than the base path", async () => { + const fetchSpy = vi + .spyOn(log, "loggedFetch") + .mockResolvedValue(new Response("", { status: 401 })); + + await runReach(); + + const [, url, init] = fetchSpy.mock.calls[0] ?? []; + expect(init?.redirect).toBe("manual"); + expect(init?.method).toBe("POST"); + // The bare base path is exactly what 301s to the internal origin. + expect(String(url)).not.toMatch(/\/codev-backend\/?$/); + expect(String(url)).toContain("/codev-backend/config"); + }); + + test("a redirect counts as reachable — the response itself proves transport", async () => { + vi.spyOn(log, "loggedFetch").mockResolvedValue( + new Response("", { + status: 301, + headers: { location: "http://netmind.viettel.vn:9096/codev-backend/" }, + }), + ); + expect((await runReach()).status).toBe("pass"); + }); + + test("401 is a pass, and says so rather than reading like a failure", async () => { + vi.spyOn(log, "loggedFetch").mockResolvedValue( + new Response("", { status: 401 }), + ); + const o = await runReach(); + expect(o.status).toBe("pass"); + expect(o.detail).toContain("expected without credentials"); + }); + + test("407 still fails — a proxy challenge means the transport is unusable", async () => { + vi.spyOn(log, "loggedFetch").mockResolvedValue( + new Response("", { status: 407 }), + ); + const o = await runReach(); + expect(o.status).toBe("fail"); + expect(o.fix).toContain("user:password@host:port"); + }); + + test("a connect timeout still fails, with the full diagnosis", async () => { + vi.spyOn(log, "loggedFetch").mockRejectedValue( + Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("Connect Timeout Error"), { + code: "UND_ERR_CONNECT_TIMEOUT", + }), + }), + ); + const o = await runReach(); + expect(o.status).toBe("fail"); + expect(o.diagnosis?.what).toMatch(/timed out/i); + }); +}); + describe("llm checks", () => { async function runLlm(key: string, ctx: object): Promise { const check = LLM_CHECKS.find((c) => c.key === key); @@ -678,6 +796,60 @@ describe("remediation output", () => { expect(lines).toContain("npm config set proxy"); }); + // A wall of export lines under an expired-token 401 buries the one + // instruction that actually helps. + test("omits proxy setup when the only failures are authentication", () => { + const lines = buildNextSteps([ + { + key: "api-key", + label: "Fetch a gateway API key", + group: "account", + status: "fail", + detail: "Backend /auth/exchange failed (401)", + fix: "Run `codevhub login --force` to re-authenticate, then re-run.", + }, + ]).join("\n"); + expect(lines).toContain("codevhub login --force"); + expect(lines).not.toContain("export HTTPS_PROXY"); + expect(lines).not.toContain("SetEnvironmentVariable"); + }); + + // The internal mirror's URL ends in `/npm-proxy`, which a loose /proxy/i + // test matched — dragging the whole export block into a run whose only + // issue was the registry setting. + test("the npm mirror URL does not count as a proxy problem", () => { + const lines = buildNextSteps([ + { + key: "npm-registry", + label: "npm registry configuration", + group: "environment", + status: "warn", + detail: "registry: https://registry.npmjs.org/", + fix: `Set the mirror: npm config set registry ${INTERNAL_NPM_REGISTRY}`, + }, + ]).join("\n"); + expect(lines).toContain(INTERNAL_NPM_REGISTRY); + expect(lines).not.toContain("export HTTPS_PROXY"); + expect(lines).not.toContain("SetEnvironmentVariable"); + }); + + // But a transport failure in the same group still warrants them — the + // signal is the diagnosis naming the proxy vars, not the group. + test("includes proxy setup when an account failure is a transport failure", () => { + const lines = buildNextSteps([ + { + key: "api-key", + label: "Fetch a gateway API key", + group: "account", + status: "fail", + detail: "Could not resolve api.example.com", + fix: "Set HTTP_PROXY, HTTPS_PROXY and NODE_USE_ENV_PROXY=1, then re-run.", + }, + ]).join("\n"); + expect(lines).toContain("NODE_USE_ENV_PROXY=1"); + expect(lines).toContain("npm config set proxy"); + }); + test("says nothing when everything passed", () => { expect( buildNextSteps([ From 9555a5bc0ca436f3ac0dc9f48803c4dad6769cfe Mon Sep 17 00:00:00 2001 From: minhn4 Date: Tue, 28 Jul 2026 18:04:09 +0700 Subject: [PATCH 04/16] fix: give `doctor`'s LLM checks the gateway URL instead of the install cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three LLM checks called backend.ts without a base URL, so it fell back to AI_GATEWAY_URL(), which reads `gateway_url` out of ~/.codev-hub/auth.json and throws when it is absent. On a machine that has never run `codevhub install` — precisely the audience this command exists for — all three failed with "Missing gateway_url in ~/.codev-hub/auth.json. Run `codevhub install`". Circular advice from a pre-flight tool, and wrong: the config check fetched the URL one step earlier and left it sitting unused in the context. doctor deliberately never writes that cache, so the value has to be threaded through. They now also skip, rather than fail, when the URL could not be fetched at all — the config check above already reports why, and repeating it three times as a failure just buries it. While verifying, a bare `fetch failed` was still reaching the user: smokeTestModel stringifies its own errors, so an unreachable gateway arrived as "Couldn't reach the gateway to test : fetch failed". That case is now diagnosed as a connectivity problem and cross-referenced to the model list check, which hit the same host and carries the full error chain. The raw string is kept for support. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/doctor.ts | 60 +++++++++++++++++++++++++++++--- tests/lib/doctor.test.ts | 74 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 128 insertions(+), 6 deletions(-) diff --git a/src/lib/doctor.ts b/src/lib/doctor.ts index c4f9831..a86bbf9 100644 --- a/src/lib/doctor.ts +++ b/src/lib/doctor.ts @@ -1219,6 +1219,13 @@ export const ACCOUNT_CHECKS: Check[] = [ // Group 4 — LLM // --------------------------------------------------------------------------- +// Every call below passes ctx.gatewayUrl explicitly. Omitting it makes +// backend.ts fall back to AI_GATEWAY_URL(), which reads `gateway_url` out of +// ~/.codev-hub/auth.json and throws when it is absent — so on a machine that +// has never run `codevhub install`, exactly the audience this command exists +// for, all three checks failed with "Run `codevhub install`". Circular advice +// from a pre-flight tool, and wrong: the config check fetched the URL one step +// earlier. `doctor` never writes that cache, so the value has to be threaded. const gatewayKeyCheck: Check = { key: "gateway-key", label: "Validate the gateway key", @@ -1227,9 +1234,15 @@ const gatewayKeyCheck: Check = { if (!ctx.apiKey) { return { status: "skip", detail: "Skipped — no API key to validate." }; } - const attempt = { url: `${ctx.gatewayUrl ?? ""}/key/info`, method: "GET" }; + if (!ctx.gatewayUrl) { + return { + status: "skip", + detail: "Skipped — the gateway URL could not be fetched.", + }; + } + const attempt = { url: `${ctx.gatewayUrl}/key/info`, method: "GET" }; return guard(attempt, async () => { - const ok = await validateApiKey(ctx.apiKey as string); + const ok = await validateApiKey(ctx.apiKey as string, ctx.gatewayUrl); return ok ? { status: "pass", detail: "Key accepted by the gateway." } : { @@ -1249,9 +1262,15 @@ const modelsCheck: Check = { if (!ctx.apiKey) { return { status: "skip", detail: "Skipped — no API key." }; } - const attempt = { url: `${ctx.gatewayUrl ?? ""}/v1/models`, method: "GET" }; + if (!ctx.gatewayUrl) { + return { + status: "skip", + detail: "Skipped — the gateway URL could not be fetched.", + }; + } + const attempt = { url: `${ctx.gatewayUrl}/v1/models`, method: "GET" }; return guard(attempt, async () => { - const models = await fetchModels(ctx.apiKey as string); + const models = await fetchModels(ctx.apiKey as string, ctx.gatewayUrl); ctx.models = models; const preview = models.slice(0, 3).join(", "); return { @@ -1274,12 +1293,43 @@ const completionCheck: Check = { if (!ctx.apiKey) { return { status: "skip", detail: "Skipped — no API key." }; } + if (!ctx.gatewayUrl) { + return { + status: "skip", + detail: "Skipped — the gateway URL could not be fetched.", + }; + } const model = ctx.models?.[0] ?? FALLBACK_MODEL; // smokeTestModel never throws — it returns a reason string. - const reason = await smokeTestModel(ctx.apiKey, model); + const reason = await smokeTestModel(ctx.apiKey, model, ctx.gatewayUrl); if (!reason) { return { status: "pass", detail: `${model} answered a test prompt.` }; } + // It stringifies its own errors, so a transport failure arrives as + // "Couldn't reach the gateway to test X: fetch failed" — the exact bare + // message this command exists to eliminate. Diagnose that case properly + // rather than echoing it; the underlying error object is gone by now, but + // the model-list check immediately above hit the same host and carries the + // full chain. + if (/^Couldn't reach the gateway/.test(reason)) { + const host = hostOf(ctx.gatewayUrl) ?? "the gateway"; + return { + status: "fail", + detail: `Could not reach the gateway at ${host} to run a test completion.`, + fix: "Fix gateway connectivity first — see the model list check above for the underlying network error.", + diagnosis: { + what: `Could not reach the gateway at ${host} to run a test completion.`, + cause: + "The request never got a response, so this is a connectivity problem rather than a model-permissions one.", + fix: "The model list check above hit the same host and reports the underlying network error — fix that first, then re-run.", + context: connectionContext({ + url: `${ctx.gatewayUrl}/v1/chat/completions`, + method: "POST", + }), + raw: [redactSecrets(reason)], + }, + }; + } return { status: "fail", detail: reason, diff --git a/tests/lib/doctor.test.ts b/tests/lib/doctor.test.ts index aa8c4a4..1ba90c4 100644 --- a/tests/lib/doctor.test.ts +++ b/tests/lib/doctor.test.ts @@ -666,6 +666,10 @@ describe("backend reachability probe", () => { }); describe("llm checks", () => { + // doctor never writes ~/.codev-hub/auth.json, so every LLM check has to be + // handed the gateway URL the config check fetched. + const GATEWAY = "https://gateway.example.com"; + async function runLlm(key: string, ctx: object): Promise { const check = LLM_CHECKS.find((c) => c.key === key); if (!check) throw new Error(`no such check: ${key}`); @@ -679,9 +683,55 @@ describe("llm checks", () => { expect(o.status).toBe("skip"); }); + // Regression: these called backend.ts without a base URL, so it fell back to + // AI_GATEWAY_URL(), which reads ~/.codev-hub/auth.json and throws when it is + // absent. On a machine that has never run `codevhub install` — the audience + // this command exists for — all three failed with "Run `codevhub install`": + // circular advice, and wrong, since the config check had just fetched the + // URL. `doctor` never writes that cache, so it must be threaded through. + describe("the gateway URL is threaded, not read from the install cache", () => { + test.each([ + ["gateway-key", () => vi.spyOn(backend, "validateApiKey")], + ["gateway-models", () => vi.spyOn(backend, "fetchModels")], + ["llm-completion", () => vi.spyOn(backend, "smokeTestModel")], + ])("%s passes ctx.gatewayUrl through", async (key, spyFor) => { + const spy = spyFor(); + // biome-ignore lint/suspicious/noExplicitAny: one spy shape per callee + (spy as any).mockResolvedValue( + key === "gateway-models" + ? ["m-alpha"] + : key === "gateway-key" + ? true + : null, + ); + await runLlm(key, { + apiKey: "k", + gatewayUrl: "https://gateway.example.com", + models: ["m-alpha"], + }); + // The URL must reach backend.ts as an argument — the last one for the + // completion (apiKey, model, baseUrl), the second otherwise. + expect(spy.mock.calls[0]).toContain("https://gateway.example.com"); + }); + + test.each([ + "gateway-key", + "gateway-models", + "llm-completion", + ])("%s skips rather than blaming the install cache when the URL is missing", async (key) => { + const o = await runLlm(key, { apiKey: "k" }); + expect(o.status).toBe("skip"); + expect(o.detail).not.toContain("auth.json"); + expect(o.detail).not.toContain("codevhub install"); + }); + }); + test("a rejected key fails with a re-auth instruction", async () => { vi.spyOn(backend, "validateApiKey").mockResolvedValue(false); - const o = await runLlm("gateway-key", { apiKey: "k" }); + const o = await runLlm("gateway-key", { + apiKey: "k", + gatewayUrl: GATEWAY, + }); expect(o.status).toBe("fail"); expect(o.fix).toContain("codevhub login --force"); }); @@ -694,6 +744,7 @@ describe("llm checks", () => { ); const o = await runLlm("llm-completion", { apiKey: "k", + gatewayUrl: GATEWAY, models: ["m-alpha"], }); expect(o.status).toBe("fail"); @@ -701,10 +752,31 @@ describe("llm checks", () => { expect(o.diagnosis?.cause).toMatch(/only proves the key exists/i); }); + // smokeTestModel stringifies its own errors, so an unreachable gateway + // arrives as "Couldn't reach the gateway to test X: fetch failed" — the + // exact bare message this command exists to eliminate. + test("an unreachable gateway is diagnosed, not echoed as `fetch failed`", async () => { + vi.spyOn(backend, "smokeTestModel").mockResolvedValue( + "Couldn't reach the gateway to test m-alpha: fetch failed", + ); + const o = await runLlm("llm-completion", { + apiKey: "k", + gatewayUrl: GATEWAY, + models: ["m-alpha"], + }); + expect(o.status).toBe("fail"); + expect(o.detail).toContain("gateway.example.com"); + expect(o.detail).not.toContain("fetch failed"); + expect(o.diagnosis?.cause).toMatch(/connectivity problem/i); + // The raw string is still preserved for support. + expect(o.diagnosis?.raw.join("\n")).toContain("fetch failed"); + }); + test("a successful completion passes and names the model", async () => { vi.spyOn(backend, "smokeTestModel").mockResolvedValue(null); const o = await runLlm("llm-completion", { apiKey: "k", + gatewayUrl: GATEWAY, models: ["m-alpha"], }); expect(o.status).toBe("pass"); From 288c6ce9ca93decc9a5c41c2d8a4cbdd5e09b5e0 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Wed, 29 Jul 2026 07:59:42 +0700 Subject: [PATCH 05/16] fix: reject a bare port at the proxy prompt instead of coercing it to an IP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHATWG URL parses bare integers as 32-bit IPv4 addresses, so typing just the port at the "Proxy (host:port)" prompt was accepted silently: `8080` became `http://0.0.31.144`, `3128` became `http://0.0.12.56`. The retry then failed as an unexplained connection timeout to an address the user never typed — against a prompt where entering only the port is a very plausible slip. Numeric-only input is now rejected at the prompt, where we can still name the mistake: `"8080" looks like just the port. Enter the host too, e.g. 10.0.0.1:8080`. Also pins the accepted formats, which were previously untested: hostnames, bracketed IPv6, embedded `user:pass@` credentials, an optional scheme, and rejection of space-separated `host port`. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/ProxyPrompt.tsx | 7 ++++++- src/lib/doctor.ts | 14 +++++++++++++- tests/lib/doctor.test.ts | 31 ++++++++++++++++++++++++++++++- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/components/ProxyPrompt.tsx b/src/components/ProxyPrompt.tsx index 980d5aa..8ab3d2d 100644 --- a/src/components/ProxyPrompt.tsx +++ b/src/components/ProxyPrompt.tsx @@ -36,8 +36,13 @@ export function ProxyPrompt({ onSubmit, readOnly = false }: ProxyPromptProps) { } const url = normalizeProxyInput(trimmed); if (!url) { + // Name the likely mistake. A bare number is both the most probable + // slip against a "host:port" prompt and the one with the worst + // silent failure, so it gets its own message. setError( - "That doesn't look like a proxy address. Use host:port, e.g. 10.0.0.1:8080", + /^\d+$/.test(trimmed) + ? `"${trimmed}" looks like just the port. Enter the host too, e.g. 10.0.0.1:${trimmed}` + : "That doesn't look like a proxy address. Use host:port, e.g. 10.0.0.1:8080", ); return; } diff --git a/src/lib/doctor.ts b/src/lib/doctor.ts index a86bbf9..862d58e 100644 --- a/src/lib/doctor.ts +++ b/src/lib/doctor.ts @@ -1654,10 +1654,22 @@ export function resetDoctorOutcome(): void { doctorOutcome.retryWithProxy = null; } -/** Normalize a user-typed `host:port` into a proxy URL. */ +/** + * Normalize a user-typed `host:port` into a proxy URL. + * + * Accepts an IPv4 address, a hostname, or a bracketed IPv6 literal, with or + * without a scheme and with optional `user:pass@` credentials. Returns null for + * anything unusable, which the prompt turns into an inline error. + */ export function normalizeProxyInput(input: string): string | null { const trimmed = input.trim(); if (!trimmed) return null; + // A bare number is the likely mistake when the prompt asks for "host:port" — + // and it is the dangerous one, because WHATWG URL parses integers as 32-bit + // IPv4 addresses: `8080` becomes `http://0.0.31.144`. That is accepted + // silently and then fails much later as an unexplained connection timeout to + // an address the user never typed. Reject it here, where we can still say why. + if (/^\d+$/.test(trimmed.replace(/^https?:\/\//i, ""))) return null; const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; diff --git a/tests/lib/doctor.test.ts b/tests/lib/doctor.test.ts index 1ba90c4..80cd210 100644 --- a/tests/lib/doctor.test.ts +++ b/tests/lib/doctor.test.ts @@ -960,7 +960,36 @@ describe("normalizeProxyInput", () => { expect(normalizeProxyInput(input)).toBe(expected); }); - test.each(["", " ", "::::"])("rejects %s", (input) => { + test.each([ + ["proxy.corp:3128", "http://proxy.corp:3128"], + // Bracketed IPv6, per normal URL syntax. + ["[::1]:8080", "http://[::1]:8080"], + // Credentials survive, for proxies that require auth. + ["user:pass@10.0.0.1:8080", "http://user:pass@10.0.0.1:8080"], + ])("%s → %s", (input, expected) => { + expect(normalizeProxyInput(input)).toBe(expected); + }); + + test.each([ + "", + " ", + "::::", + "10.0.0.1 8080", + "10.0.0.1:8080:9", + ])("rejects %s", (input) => { + expect(normalizeProxyInput(input)).toBeNull(); + }); + + // WHATWG URL parses bare integers as 32-bit IPv4 addresses, so `8080` + // silently became `http://0.0.31.144` — accepted, then failing much later as + // a timeout to an address the user never typed. Typing only the port is a + // very plausible slip against a prompt that asks for "host:port". + test.each([ + "8080", + "3128", + "0", + "http://8080", + ])("rejects the bare port %s instead of coercing it to an IP", (input) => { expect(normalizeProxyInput(input)).toBeNull(); }); }); From 9af121c6c4208aa86c038d14cfa1587c1f97553d Mon Sep 17 00:00:00 2001 From: minhn4 Date: Wed, 29 Jul 2026 08:10:14 +0700 Subject: [PATCH 06/16] feat: always offer the proxy prompt, and show examples of what to type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt was suppressed when a proxy was already configured and active, on the reasoning that "it's set up, so something else is wrong". That was backwards: a wrong proxy address is among the likeliest reasons the network checks failed at all, and suppressing the prompt left exactly that user with no way to try a different one. The retry sentinel is now the only guard, so it is still offered at most once per run. When a proxy is present the copy shifts from "do you need a proxy?" to "is this one wrong?" — it names the current value, and Enter keeps it rather than skipping. The field also lists concrete examples now. "host:port" alone left real questions unanswered — does a hostname work, how do I pass a password, do I need to type http:// — so there is one example per question: 10.60.129.1:3128 IP and port proxy.corp.vn:8080 hostname and port user:pass@10.60.129.1:3128 proxy that needs a login http://10.60.129.1:3128 full URL (http:// is assumed if you omit it) Also fixes a self-contradiction spotted while reviewing the rendered output: an ECONNREFUSED carrying no address fell back to the request host, so the report read "Connection refused by netmind.viettel.vn" immediately above "That address is your proxy". The proxy claim is now made only when the error actually named the address it tried. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 4 +- src/DoctorApp.tsx | 38 +++++++++++------ src/components/ProxyPrompt.tsx | 75 ++++++++++++++++++++++++++++++---- src/lib/doctor.ts | 10 ++++- tests/DoctorApp.test.tsx | 36 +++++++++++++--- tests/lib/doctor.test.ts | 14 +++++++ 6 files changed, 147 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c9bdc03..2c01b53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,7 +194,9 @@ Pre-flight for everything `install` depends on, so users on a locked-down networ `Login`/`FetchApiKey` render `describeFailure`, which upgrades **transport** errors (those with an `err.cause`) to the compact diagnosis while leaving a backend's own precise HTTP message alone — proxy-oriented reasoning about `Backend /config failed (403)` would be actively misleading. Single-line reasons stay inline (`Login failed: `); only a multi-line diagnosis breaks onto its own lines. `Login` also takes an optional `onError`, which hands failure to the parent and drops its retry prompt — `doctor` records login as one check among many and must reach its summary. -**The re-exec handoff.** When the network group fails, `DoctorApp` offers a proxy prompt and, on submit, records `doctorOutcome.retryWithProxy` and exits — `index.tsx` spawns the retry after `waitUntilExit()` resolves. It **cannot** happen inside the component: `spawnSync` with inherited stdio while Ink still owns the TTY corrupts the terminal. `CODEV_DOCTOR_PROXY=1` on the child stops the prompt being offered twice. +**The re-exec handoff.** When the network group fails, `DoctorApp` offers a proxy prompt and, on submit, records `doctorOutcome.retryWithProxy` and exits — `index.tsx` spawns the retry after `waitUntilExit()` resolves. It **cannot** happen inside the component: `spawnSync` with inherited stdio while Ink still owns the TTY corrupts the terminal. `CODEV_DOCTOR_PROXY=1` on the child stops the prompt being offered twice, and is the *only* guard on it. + +The prompt is offered **whether or not a proxy is already configured**. An earlier revision suppressed it when one was active, reasoning that "it's set up, so something else is wrong" — that was backwards: a wrong proxy address is among the likeliest reasons the checks failed, and suppressing the prompt left exactly that user with no way to try another. When a proxy is present the copy shifts from "do you need a proxy?" to "is this one wrong?", naming the current value, and Enter keeps it rather than skipping. **`PREFLIGHT_CHECKS` vs `ENVIRONMENT_CHECKS`.** `SetupApp` runs the former at the head of install/config. It is strictly pure — `process.versions` and `process.env` only. The npm checks are excluded because each spawns `npm config get` (~300ms) and install runs npm for real moments later anyway; `system-ca` is excluded because the OS-store read blocks the event loop for 300ms+ on Windows, which is precisely the stall documented in the TLS section above. The pre-flight is **advisory and never blocks** — the one condition that genuinely cannot proceed (Node below the floor) is already refused at startup in `index.tsx`. diff --git a/src/DoctorApp.tsx b/src/DoctorApp.tsx index 0e1dbf3..af8e640 100644 --- a/src/DoctorApp.tsx +++ b/src/DoctorApp.tsx @@ -26,7 +26,7 @@ import { STATE_CHECKS, } from "@/lib/doctor.js"; import { logDebug } from "@/lib/log.js"; -import { hasProxyConfigured, readProxyEnv } from "@/lib/proxy.js"; +import { readProxyEnv } from "@/lib/proxy.js"; type Phase = | "preparing" @@ -138,17 +138,21 @@ export function DoctorApp({ force = false }: DoctorAppProps) { })); }).then((results) => { // The network group is the proxy gate: a hard failure there is the - // signal that this machine may need a proxy we don't know about. Offer - // the prompt once — never when we're already the retry child, and never - // when a proxy is configured AND active (it's set up; something else is - // wrong, and asking again would just be noise). - if (group === "network" && hasFailure(results)) { - const env = readProxyEnv(); - const proxyAlreadyWorking = hasProxyConfigured(env) && env.useEnvProxy; - if (!alreadyRetriedWithProxy() && !proxyAlreadyWorking) { - setPhase("proxy-prompt"); - return; - } + // signal that this machine may need a different proxy than it has. + // Offered whether or not one is already configured — an earlier + // revision suppressed it when a proxy was active, on the reasoning + // that "it's set up, so something else is wrong". That was backwards: + // a wrong proxy address is among the likeliest reasons the checks + // failed, and suppressing the prompt left exactly that user with no + // way to try another one. The only guard is the retry sentinel, so a + // child that still fails prints its summary instead of asking again. + if ( + group === "network" && + hasFailure(results) && + !alreadyRetriedWithProxy() + ) { + setPhase("proxy-prompt"); + return; } setPhase(next); }); @@ -239,6 +243,11 @@ export function DoctorApp({ force = false }: DoctorAppProps) { }; }; + // What the environment already points at, so the prompt can ask "is this one + // wrong?" rather than "do you need a proxy?". + const proxyEnv = readProxyEnv(); + const currentProxy = proxyEnv.httpsProxy ?? proxyEnv.httpProxy; + const allOutcomes = [ ...outcomes.environment, ...outcomes.network, @@ -280,7 +289,10 @@ export function DoctorApp({ force = false }: DoctorAppProps) { {phase === "proxy-prompt" && ( - + )} diff --git a/src/components/ProxyPrompt.tsx b/src/components/ProxyPrompt.tsx index 8ab3d2d..3e7ac05 100644 --- a/src/components/ProxyPrompt.tsx +++ b/src/components/ProxyPrompt.tsx @@ -5,18 +5,44 @@ import { normalizeProxyInput } from "@/lib/doctor.js"; interface ProxyPromptProps { /** Called with a normalized proxy URL, or null when the user skips. */ onSubmit: (proxyUrl: string | null) => void; + /** + * The proxy already in the environment, if any. Its presence changes the + * question from "do you need a proxy?" to "is this one wrong?", which is the + * more useful question once the network has failed *despite* a proxy. + */ + currentProxy?: string | null; readOnly?: boolean; } +// Concrete forms, because "host:port" alone leaves real questions unanswered: +// does a hostname work, how do I pass a password, do I need http://. Each line +// exists to answer one of those. +const EXAMPLES: [string, string][] = [ + ["10.60.129.1:3128", "IP and port"], + ["proxy.corp.vn:8080", "hostname and port"], + ["user:pass@10.60.129.1:3128", "proxy that needs a login"], + ["http://10.60.129.1:3128", "full URL (http:// is assumed if you omit it)"], +]; + +const EXAMPLE_WIDTH = Math.max(...EXAMPLES.map(([e]) => e.length)); + /** - * Single `host:port` field, offered when the network checks fail and no working - * proxy is configured. + * Single `host:port` field, offered whenever the network checks fail. + * + * Deliberately offered even when a proxy is already configured: "the proxy is + * set, so the proxy is fine" was the wrong inference — a *wrong* proxy address + * is one of the most likely reasons the checks failed at all, and suppressing + * the prompt left that user with no way to try another one. * * Hand-rolled on `useInput` like ManualCredentials — the repo deliberately * carries no text-input dependency, and one optional field does not justify * adding one. */ -export function ProxyPrompt({ onSubmit, readOnly = false }: ProxyPromptProps) { +export function ProxyPrompt({ + onSubmit, + currentProxy = null, + readOnly = false, +}: ProxyPromptProps) { const [value, setValue] = useState(""); const [error, setError] = useState(null); const [submitted, setSubmitted] = useState(false); @@ -75,16 +101,47 @@ export function ProxyPrompt({ onSubmit, readOnly = false }: ProxyPromptProps) { return ( - - { - "The network checks failed. If this machine reaches the internet through a proxy, enter it here and CoDev will re-run the checks with it applied." - } - + {currentProxy ? ( + <> + + {`The network checks failed even though a proxy is configured (${currentProxy}).`} + + + { + "If that address is wrong, enter the correct one and CoDev will re-run the checks with it." + } + + + ) : ( + + { + "The network checks failed. If this machine reaches the internet through a proxy, enter it here and CoDev will re-run the checks with it applied." + } + + )} {"Nothing is written to disk — this applies to this run only."} + + + {"Examples:"} + {EXAMPLES.map(([example, note]) => ( + + {" "} + + {example} + + {note} + + ))} + + - {"Proxy (host:port), or Enter to skip: "} + + {currentProxy + ? "Proxy (host:port), or Enter to keep the current one: " + : "Proxy (host:port), or Enter to skip: "} + {value} diff --git a/src/lib/doctor.ts b/src/lib/doctor.ts index 862d58e..bd16973 100644 --- a/src/lib/doctor.ts +++ b/src/lib/doctor.ts @@ -389,9 +389,15 @@ export function diagnoseError(err: unknown, attempt: Attempt = {}): Diagnosis { `Connection refused by ${root.address ?? host}${ root.port !== undefined ? `:${root.port}` : "" }.`, - proxied + // Only call it "your proxy" when the error actually named the + // address it tried. Without one we fall back to the request host, + // and asserting that the *destination* is the proxy contradicts the + // line right above it. + proxied && root.address ? "That address is your proxy — nothing is listening on it, so the proxy host or port is wrong." - : "Nothing accepted a connection on that address.", + : proxied + ? "A proxy is configured, so the refusal most likely came from the proxy rather than the destination." + : "Nothing accepted a connection on that address.", proxied ? "Double-check HTTP_PROXY / HTTPS_PROXY. The value must include the scheme and port, e.g. http://10.0.0.1:8080." : NO_PROXY_SET_FIX, diff --git a/tests/DoctorApp.test.tsx b/tests/DoctorApp.test.tsx index 0911d5b..a8f2472 100644 --- a/tests/DoctorApp.test.tsx +++ b/tests/DoctorApp.test.tsx @@ -288,9 +288,10 @@ describe("DoctorApp", () => { expect(frames.join("\n")).not.toContain("Proxy (host:port)"); }); - // A working proxy means the problem is elsewhere; asking for one again - // would just be noise. - test("no prompt when a proxy is already configured and active", async () => { + // A wrong proxy address is one of the likeliest reasons the checks failed, + // so the prompt must still be offered — an earlier revision suppressed it + // here and left exactly that user with no way to try a different one. + test("still prompts when a proxy is already configured, naming the current one", async () => { vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); vi.stubEnv("NODE_USE_ENV_PROXY", "1"); vi.spyOn(log, "loggedFetch").mockRejectedValue( @@ -303,8 +304,33 @@ describe("DoctorApp", () => { stubHappyPath(); const { frames } = render(); - await waitForFrame(frames, "check(s) failed"); - expect(frames.join("\n")).not.toContain("Proxy (host:port)"); + await waitForFrame(frames, "Proxy (host:port)"); + const output = frames.join("\n"); + // The question shifts from "do you need a proxy?" to "is this one wrong?". + expect(output).toContain("even though a proxy is configured"); + expect(output).toContain("http://10.0.0.1:8080"); + expect(output).toContain("Enter to keep the current one"); + }); + + test("offers concrete examples of what to type", async () => { + vi.spyOn(log, "loggedFetch").mockRejectedValue( + Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("connect ECONNREFUSED 1.2.3.4:443"), { + code: "ECONNREFUSED", + }), + }), + ); + stubHappyPath(); + + const { frames } = render(); + await waitForFrame(frames, "Proxy (host:port)"); + const output = frames.join("\n"); + expect(output).toContain("Examples:"); + // Each example answers a question "host:port" alone leaves open. + expect(output).toContain("10.60.129.1:3128"); + expect(output).toContain("proxy.corp.vn:8080"); + expect(output).toContain("user:pass@"); + expect(output).toContain("http:// is assumed"); }); // Login must not park the run on a retry prompt — the summary is the value. diff --git a/tests/lib/doctor.test.ts b/tests/lib/doctor.test.ts index 80cd210..9e566ee 100644 --- a/tests/lib/doctor.test.ts +++ b/tests/lib/doctor.test.ts @@ -319,6 +319,20 @@ describe("diagnoseError", () => { expect(d.fix).toContain("HTTP_PROXY"); }); + // Without an address in the error we fall back to the request host, and + // claiming the *destination* is the proxy contradicts the line above it. + test("an addressless refusal does not call the destination the proxy", () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + const d = diagnoseError( + fetchError("ECONNREFUSED", "connect ECONNREFUSED"), + { url: "https://api.example.com/x" }, + ); + expect(d.what).toContain("api.example.com"); + expect(d.cause).not.toContain("That address is your proxy"); + expect(d.cause).toMatch(/most likely came from the proxy/i); + }); + test("a NO_PROXY exemption for the backend is reported in the context", () => { vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); vi.stubEnv("NODE_USE_ENV_PROXY", "1"); From f4c0e3cd55e67eb52cda36ab2a388cc5b646fb14 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Wed, 29 Jul 2026 08:24:49 +0700 Subject: [PATCH 07/16] test: pin the proxy examples on the already-configured variant too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The examples render on both variants, but only the no-proxy path asserted them — nothing would have caught them disappearing from the prompt shown to someone correcting a wrong proxy, who needs the syntax just as much. Co-Authored-By: Claude Opus 5 (1M context) --- tests/DoctorApp.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/DoctorApp.test.tsx b/tests/DoctorApp.test.tsx index a8f2472..a08a31b 100644 --- a/tests/DoctorApp.test.tsx +++ b/tests/DoctorApp.test.tsx @@ -310,6 +310,10 @@ describe("DoctorApp", () => { expect(output).toContain("even though a proxy is configured"); expect(output).toContain("http://10.0.0.1:8080"); expect(output).toContain("Enter to keep the current one"); + // The examples belong on BOTH variants — someone correcting a wrong proxy + // needs the syntax just as much as someone entering their first one. + expect(output).toContain("Examples:"); + expect(output).toContain("user:pass@"); }); test("offers concrete examples of what to type", async () => { From 4a257c6d8b9bff98843a1d44485d91820de6a704 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Wed, 29 Jul 2026 08:28:35 +0700 Subject: [PATCH 08/16] test: spell out the command the proxy retry actually runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rerunDoctorWithProxy` lived in index.tsx, unexported and untested — the only coverage was DoctorApp asserting it does NOT spawn. So the one place `doctor` shells out had nothing pinning its argv or environment, and answering "what command does this actually run?" meant reading the source. Moved into lib/doctor.ts, which is also the right layer (index.tsx is a dispatcher; logic belongs in lib/). The tests now state the command literally: [...process.execArgv] doctor [...args] with assertions that it invokes node directly rather than a shell or the `codevhub` bin, that stdio is inherited, and that execArgv is forwarded — which matters because a `pnpm dev` run carries tsx's loader flags and a child without them cannot load TypeScript at all. Also pins the environment (HTTP_PROXY/HTTPS_PROXY, NODE_USE_ENV_PROXY=1, NODE_USE_SYSTEM_CA=1, CODEV_DOCTOR_PROXY=1), that a NO_PROXY entry covering the backend is dropped from the child while the user's own environment is left untouched, and that the child's exit code propagates — including a killed child reporting failure rather than success. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 4 +- src/index.tsx | 48 ++--------------- src/lib/doctor.ts | 54 ++++++++++++++++++++ tests/lib/doctor.test.ts | 108 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 44 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2c01b53..3c06eed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,7 +194,9 @@ Pre-flight for everything `install` depends on, so users on a locked-down networ `Login`/`FetchApiKey` render `describeFailure`, which upgrades **transport** errors (those with an `err.cause`) to the compact diagnosis while leaving a backend's own precise HTTP message alone — proxy-oriented reasoning about `Backend /config failed (403)` would be actively misleading. Single-line reasons stay inline (`Login failed: `); only a multi-line diagnosis breaks onto its own lines. `Login` also takes an optional `onError`, which hands failure to the parent and drops its retry prompt — `doctor` records login as one check among many and must reach its summary. -**The re-exec handoff.** When the network group fails, `DoctorApp` offers a proxy prompt and, on submit, records `doctorOutcome.retryWithProxy` and exits — `index.tsx` spawns the retry after `waitUntilExit()` resolves. It **cannot** happen inside the component: `spawnSync` with inherited stdio while Ink still owns the TTY corrupts the terminal. `CODEV_DOCTOR_PROXY=1` on the child stops the prompt being offered twice, and is the *only* guard on it. +**The re-exec handoff.** When the network group fails, `DoctorApp` offers a proxy prompt and, on submit, records `doctorOutcome.retryWithProxy` and exits — `index.tsx` calls `rerunDoctorWithProxy` after `waitUntilExit()` resolves. It **cannot** happen inside the component: `spawnSync` with inherited stdio while Ink still owns the TTY corrupts the terminal. `CODEV_DOCTOR_PROXY=1` on the child stops the prompt being offered twice, and is the *only* guard on it. + +The spawn itself lives in `lib/doctor.ts`, not the dispatcher, so the exact command is assertable — `tests/lib/doctor.test.ts` spells it out rather than describing it. It runs `node [...process.execArgv] doctor [...args]` with `stdio: "inherit"`: node directly on the script, never a shell and never the `codevhub` bin. Forwarding `execArgv` is load-bearing — a `pnpm dev` run carries tsx's loader flags, and a child without them cannot load TypeScript and dies immediately. The prompt is offered **whether or not a proxy is already configured**. An earlier revision suppressed it when one was active, reasoning that "it's set up, so something else is wrong" — that was backwards: a wrong proxy address is among the likeliest reasons the checks failed, and suppressing the prompt left exactly that user with no way to try another. When a proxy is present the copy shifts from "do you need a proxy?" to "is this one wrong?", naming the current value, and Enter keeps it rather than skipping. diff --git a/src/index.tsx b/src/index.tsx index 91117f7..b086e05 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -15,12 +15,12 @@ import { nodeVersionMeets, RECOMMENDED_NODE, } from "@/lib/const.js"; -import { DOCTOR_PROXY_ENV, doctorOutcome } from "@/lib/doctor.js"; +import { doctorOutcome, rerunDoctorWithProxy } from "@/lib/doctor.js"; import { printHelp, printVersion } from "@/lib/help.js"; -import { currentTraceId, initLogging } from "@/lib/log.js"; +import { initLogging } from "@/lib/log.js"; import { runLogs } from "@/lib/logs.js"; -import { applyEnvProxy, backendHost, stripNoProxyFor } from "@/lib/proxy.js"; -import { ensureNodeSqliteOrReexec, spawner } from "@/lib/reexec.js"; +import { applyEnvProxy } from "@/lib/proxy.js"; +import { ensureNodeSqliteOrReexec } from "@/lib/reexec.js"; import { ensureFreshGatewayKey } from "@/lib/refresh.js"; import { RESTORE_AGENTS, @@ -93,44 +93,6 @@ function flagValue(argv: string[], name: string): string | undefined { return undefined; } -// Re-run `codevhub doctor` with the proxy the user just typed, so they see the -// fix actually work before being told to make it permanent. Nothing is written -// to disk; the settings live only in the child's environment. -function rerunDoctorWithProxy(proxy: { http: string; https: string }): number { - const selfPath = process.argv[1]; - if (!selfPath) { - console.error("Could not determine the CLI path to re-run."); - return 1; - } - const traceId = currentTraceId(); - const env: NodeJS.ProcessEnv = { - ...process.env, - HTTP_PROXY: proxy.http, - HTTPS_PROXY: proxy.https, - NODE_USE_ENV_PROXY: "1", - // Intercepting proxies re-sign TLS with a corporate root, so reading the - // OS trust store is part of "try it with the proxy", not a separate step. - NODE_USE_SYSTEM_CA: "1", - // Offer the prompt only once — if the retry still fails, the summary must - // be allowed to print rather than asking again. - [DOCTOR_PROXY_ENV]: "1", - ...(traceId ? { CODEV_TRACE_PARENT: traceId } : {}), - }; - // A NO_PROXY entry covering our own backend would send that traffic direct - // and defeat the proxy we're testing — the documented cause of "Login - // failed". Drop it for the child only; the user's environment is untouched. - const host = backendHost(); - if (env.NO_PROXY) env.NO_PROXY = stripNoProxyFor(env.NO_PROXY, host); - if (env.no_proxy) env.no_proxy = stripNoProxyFor(env.no_proxy, host); - - const result = spawner.spawnSync( - process.execPath, - [...process.execArgv, selfPath, "doctor", ...args], - { stdio: "inherit", env }, - ); - return result.status ?? 1; -} - // Diagnostic logging (~/.codev-hub/logs/codev-YYYYMMDD.ndjson, ECS NDJSON) starts // before dispatch so every command logs its start/end and crashes. File-only — // never stdout/stderr, which the Ink apps own. @@ -224,7 +186,7 @@ switch (command) { // still owns the TTY corrupts the terminal. It records the intent instead // and we act on it here, now that Ink has unmounted. const retry = doctorOutcome.retryWithProxy; - if (retry) process.exit(rerunDoctorWithProxy(retry)); + if (retry) process.exit(rerunDoctorWithProxy(retry, args)); process.exit(doctorOutcome.exitCode); break; } diff --git a/src/lib/doctor.ts b/src/lib/doctor.ts index bd16973..ecbce19 100644 --- a/src/lib/doctor.ts +++ b/src/lib/doctor.ts @@ -24,6 +24,7 @@ import { RECOMMENDED_NODE, } from "@/lib/const.js"; import { + currentTraceId, logDebug, logError, loggedFetch, @@ -45,7 +46,9 @@ import { type ProxyEnv, proxyAutoEnabled, readProxyEnv, + stripNoProxyFor, } from "@/lib/proxy.js"; +import { spawner } from "@/lib/reexec.js"; import { agentOnPath } from "@/lib/run.js"; import { detectInstalledShims } from "@/lib/shims.js"; import { isCertError, tlsApi } from "@/lib/tls.js"; @@ -1695,3 +1698,54 @@ export function alreadyRetriedWithProxy(): boolean { const v = process.env[DOCTOR_PROXY_ENV]; return v !== undefined && v !== "" && v !== "0"; } + +/** + * Re-run `codevhub doctor` with the proxy the user just typed, so they see the + * fix actually work before being told to make it permanent. Nothing is written + * to disk — the settings live only in the child's environment. + * + * Lives here rather than in index.tsx so the exact command and environment are + * assertable; the dispatcher only decides *when* to call it. It must still be + * invoked from index.tsx after Ink has unmounted: `spawnSync` with inherited + * stdio while Ink owns the TTY corrupts the terminal. + */ +export function rerunDoctorWithProxy( + proxy: { http: string; https: string }, + args: string[], +): number { + const selfPath = process.argv[1]; + if (!selfPath) { + process.stderr.write("Could not determine the CLI path to re-run.\n"); + return 1; + } + const traceId = currentTraceId(); + const env: NodeJS.ProcessEnv = { + ...process.env, + HTTP_PROXY: proxy.http, + HTTPS_PROXY: proxy.https, + NODE_USE_ENV_PROXY: "1", + // Intercepting proxies re-sign TLS with a corporate root, so reading the + // OS trust store is part of "try it with the proxy", not a separate step. + NODE_USE_SYSTEM_CA: "1", + // Offer the prompt only once — if the retry still fails, the summary must + // be allowed to print rather than asking again. + [DOCTOR_PROXY_ENV]: "1", + ...(traceId ? { CODEV_TRACE_PARENT: traceId } : {}), + }; + // A NO_PROXY entry covering our own backend would send that traffic direct + // and defeat the proxy we're testing — the documented cause of "Login + // failed". Drop it for the child only; the user's environment is untouched. + const host = backendHost(); + if (env.NO_PROXY) env.NO_PROXY = stripNoProxyFor(env.NO_PROXY, host); + if (env.no_proxy) env.no_proxy = stripNoProxyFor(env.no_proxy, host); + + // execArgv is forwarded so the child keeps whatever flags this process was + // started with — without it, a `pnpm dev` run (node + tsx loader flags) + // would spawn a child that cannot load TypeScript and dies immediately. + const result = spawner.spawnSync( + process.execPath, + [...process.execArgv, selfPath, "doctor", ...args], + { stdio: "inherit", env }, + ); + return result.status ?? 1; +} diff --git a/tests/lib/doctor.test.ts b/tests/lib/doctor.test.ts index 9e566ee..e3c16de 100644 --- a/tests/lib/doctor.test.ts +++ b/tests/lib/doctor.test.ts @@ -3,6 +3,7 @@ import * as backend from "@/lib/backend.js"; import { buildNextSteps, type CheckOutcome, + DOCTOR_PROXY_ENV, describeFailure, diagnoseError, diagnoseExec, @@ -17,12 +18,14 @@ import { PREFLIGHT_CHECKS, persistProxyInstructions, renderDiagnosisCompact, + rerunDoctorWithProxy, runChecks, } from "@/lib/doctor.js"; import * as log from "@/lib/log.js"; import * as npm from "@/lib/npm.js"; import * as proxy from "@/lib/proxy.js"; import { PROXY_APPLIED_ENV } from "@/lib/proxy.js"; +import * as reexec from "@/lib/reexec.js"; import * as tls from "@/lib/tls.js"; const PROXY_VARS = [ @@ -964,6 +967,111 @@ describe("remediation output", () => { }); }); +// The retry is the one place `doctor` shells out, and what it runs has to be +// exactly reproducible by hand for support. These spell the command out. +describe("the proxy retry command", () => { + const PROXY = { http: "http://10.0.0.1:8080", https: "http://10.0.0.1:8080" }; + + function captureSpawn() { + return ( + vi + .spyOn(reexec.spawner, "spawnSync") + // biome-ignore lint/suspicious/noExplicitAny: minimal SpawnSyncReturns stub + .mockReturnValue({ status: 0 } as any) + ); + } + + test("runs `node doctor`, forwarding argv and node's own flags", () => { + const spawn = captureSpawn(); + rerunDoctorWithProxy(PROXY, ["--force"]); + + const [file, argv, opts] = spawn.mock.calls[0] ?? []; + // The whole command, spelled out: + // [...node flags] doctor --force + expect([file, ...(argv as string[])]).toEqual([ + process.execPath, + ...process.execArgv, + process.argv[1], + "doctor", + "--force", + ]); + // It re-invokes node on the script directly — no shell, no `codevhub` bin. + expect(file).toBe(process.execPath); + expect(opts?.shell).toBeUndefined(); + // Inherited stdio is what makes the retry look like one continuous session. + expect(opts?.stdio).toBe("inherit"); + }); + + // Without execArgv a `pnpm dev` run (node + tsx loader flags) would spawn a + // child that cannot load TypeScript and dies immediately. + test("forwards node's own exec flags so a tsx dev run still works", () => { + const spawn = captureSpawn(); + const original = process.execArgv; + Object.defineProperty(process, "execArgv", { + value: ["--import", "file:///tmp/tsx/loader.mjs"], + configurable: true, + }); + try { + rerunDoctorWithProxy(PROXY, []); + } finally { + Object.defineProperty(process, "execArgv", { + value: original, + configurable: true, + }); + } + const argv = spawn.mock.calls[0]?.[1] as string[]; + expect(argv.slice(0, 2)).toEqual([ + "--import", + "file:///tmp/tsx/loader.mjs", + ]); + expect(argv).toContain("doctor"); + }); + + test("sets exactly the environment the retry needs", () => { + const spawn = captureSpawn(); + rerunDoctorWithProxy(PROXY, []); + + const env = spawn.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv; + expect(env.HTTP_PROXY).toBe("http://10.0.0.1:8080"); + expect(env.HTTPS_PROXY).toBe("http://10.0.0.1:8080"); + // Node reads this only at bootstrap, which is the entire reason the retry + // is a new process rather than an in-place re-run. + expect(env.NODE_USE_ENV_PROXY).toBe("1"); + // An intercepting proxy re-signs TLS, so trusting the OS store is part of + // "try it with the proxy", not a separate step. + expect(env.NODE_USE_SYSTEM_CA).toBe("1"); + // Stops the child prompting again if it still fails. + expect(env[DOCTOR_PROXY_ENV]).toBe("1"); + }); + + // A NO_PROXY entry covering our backend routes that traffic around the very + // proxy being tested — the documented cause of "Login failed". + test("drops a NO_PROXY entry that would bypass the proxy under test", () => { + vi.stubEnv("NO_PROXY", "localhost,*.viettel.vn,127.0.0.1"); + const spawn = captureSpawn(); + rerunDoctorWithProxy(PROXY, []); + + const env = spawn.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv; + expect(env.NO_PROXY).toBe("localhost,127.0.0.1"); + // The user's own environment is untouched — only the child's copy changes. + expect(process.env.NO_PROXY).toBe("localhost,*.viettel.vn,127.0.0.1"); + }); + + test("propagates the child's exit code", () => { + vi.spyOn(reexec.spawner, "spawnSync") + // biome-ignore lint/suspicious/noExplicitAny: minimal SpawnSyncReturns stub + .mockReturnValue({ status: 3 } as any); + expect(rerunDoctorWithProxy(PROXY, [])).toBe(3); + }); + + test("a killed child (null status) is reported as a failure", () => { + vi.spyOn(reexec.spawner, "spawnSync") + // biome-ignore lint/suspicious/noExplicitAny: minimal SpawnSyncReturns stub + .mockReturnValue({ status: null } as any); + expect(rerunDoctorWithProxy(PROXY, [])).toBe(1); + }); +}); + describe("normalizeProxyInput", () => { test.each([ ["10.0.0.1:8080", "http://10.0.0.1:8080"], From 7f5a6614715fd24bcc26419a3a9e109ddf60ba7f Mon Sep 17 00:00:00 2001 From: minhn4 Date: Wed, 29 Jul 2026 08:52:40 +0700 Subject: [PATCH 09/16] test: inventory every command `codevhub doctor` spawns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "What is this thing actually running on my machine?" is a fair question for a diagnostic tool people run on locked-down boxes, often while on a call with IT — and the only answer was "read the source". These tests are the answer: the complete ordered list per group, failing the moment a command is added, removed or reworded. environment npm -v, npm config get {prefix,registry,proxy,https-proxy, strict-ssl,cafile} network npm ping, npm view codev-ai version account/llm none — pure HTTP state npm root -g pre-flight none (it is embedded in `codevhub install`, where even one ~300ms `npm config get` would be felt on every run) Ten processes for a full run, plus a guard that every one is `npm` and none mutates — no install, uninstall, publish, link or `config set`. `doctor` promises to be read-only and that assertion is what enforces it. Measuring this found `installedAgentsCheck` spawning `npm root -g` four times, in series, one per agent, for an answer that cannot differ between them — roughly a second wasted in a command people run while already frustrated. It now resolves the root once. The spy sits at the `execFile` boundary rather than on `lib/npm.ts#execAsync` because helpers inside npm.ts call `execAsync` through their module-local binding, which a spy on the export cannot intercept. An earlier draft used that spy and reported the state group as spawning nothing while it was in fact shelling out four times — the bug above would have stayed invisible. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 + src/lib/doctor.ts | 26 ++-- tests/lib/doctor-commands.test.ts | 196 ++++++++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 12 deletions(-) create mode 100644 tests/lib/doctor-commands.test.ts diff --git a/AGENTS.md b/AGENTS.md index 3c06eed..43989a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -196,6 +196,8 @@ Pre-flight for everything `install` depends on, so users on a locked-down networ **The re-exec handoff.** When the network group fails, `DoctorApp` offers a proxy prompt and, on submit, records `doctorOutcome.retryWithProxy` and exits — `index.tsx` calls `rerunDoctorWithProxy` after `waitUntilExit()` resolves. It **cannot** happen inside the component: `spawnSync` with inherited stdio while Ink still owns the TTY corrupts the terminal. `CODEV_DOCTOR_PROXY=1` on the child stops the prompt being offered twice, and is the *only* guard on it. +**Every child process is inventoried.** `tests/lib/doctor-commands.test.ts` asserts the complete, ordered list of commands each group spawns — 10 for a full run, all `npm`, none mutating — so a new subprocess shows up in review rather than quietly appearing on users' machines. It spies at the **`execFile` boundary**, not on `npm.ts#execAsync`: helpers inside npm.ts call `execAsync` through their module-local binding, which a spy on the export cannot intercept. An earlier draft used that spy and reported the `state` group as spawning nothing while it was in fact shelling out four times. That same measurement is what caught `installedAgentsCheck` running `npm root -g` once per agent; it now resolves the root once. + The spawn itself lives in `lib/doctor.ts`, not the dispatcher, so the exact command is assertable — `tests/lib/doctor.test.ts` spells it out rather than describing it. It runs `node [...process.execArgv] doctor [...args]` with `stdio: "inherit"`: node directly on the script, never a shell and never the `codevhub` bin. Forwarding `execArgv` is load-bearing — a `pnpm dev` run carries tsx's loader flags, and a child without them cannot load TypeScript and dies immediately. The prompt is offered **whether or not a proxy is already configured**. An earlier revision suppressed it when one was active, reasoning that "it's set up, so something else is wrong" — that was backwards: a wrong proxy address is among the likeliest reasons the checks failed, and suppressing the prompt left exactly that user with no way to try another. When a proxy is present the copy shifts from "do you need a proxy?" to "is this one wrong?", naming the current value, and Enter keeps it rather than skipping. diff --git a/src/lib/doctor.ts b/src/lib/doctor.ts index ecbce19..5ccb095 100644 --- a/src/lib/doctor.ts +++ b/src/lib/doctor.ts @@ -32,13 +32,7 @@ import { logWarn, redactSecrets, } from "@/lib/log.js"; -import { - CLI, - detectInstalledViaNpm, - execAsync, - type NpmTool, - PKG, -} from "@/lib/npm.js"; +import { CLI, execAsync, type NpmTool, npmGlobalRoot, PKG } from "@/lib/npm.js"; import { backendHost, hasProxyConfigured, @@ -1375,12 +1369,20 @@ const installedAgentsCheck: Check = { label: "Agents installed", group: "state", run: async () => { - const found: string[] = []; - for (const tool of NPM_TOOLS) { - if (await detectInstalledViaNpm(tool)) { - found.push(`${PKG[tool]} (${CLI[tool]})`); - } + // Resolve the global root ONCE. `detectInstalledViaNpm` looks it up per + // call, so a loop over four agents spawned `npm root -g` four times, in + // series, for an answer that cannot differ between them — about a second + // of pure waste in a command people run while already frustrated. + const root = await npmGlobalRoot(); + if (!root) { + return { + status: "skip", + detail: "Could not resolve npm's global directory.", + }; } + const found = NPM_TOOLS.filter((tool) => + existsSync(join(root, ...PKG[tool].split("/"))), + ).map((tool) => `${PKG[tool]} (${CLI[tool]})`); return found.length > 0 ? { status: "pass", detail: found.join(", ") } : { diff --git a/tests/lib/doctor-commands.test.ts b/tests/lib/doctor-commands.test.ts new file mode 100644 index 0000000..cb2d4ea --- /dev/null +++ b/tests/lib/doctor-commands.test.ts @@ -0,0 +1,196 @@ +import * as child_process from "node:child_process"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + ACCOUNT_CHECKS, + type Check, + ENVIRONMENT_CHECKS, + LLM_CHECKS, + NETWORK_CHECKS, + PREFLIGHT_CHECKS, + rerunDoctorWithProxy, + runChecks, + STATE_CHECKS, +} from "@/lib/doctor.js"; +import * as log from "@/lib/log.js"; +import * as proxy from "@/lib/proxy.js"; +import * as reexec from "@/lib/reexec.js"; +import * as tls from "@/lib/tls.js"; + +/** + * The complete inventory of every child process `codevhub doctor` spawns. + * + * `doctor` is a diagnostic tool people run on locked-down machines, often while + * on a call with IT — "what is this thing actually running?" is a fair question + * and needs an answer that isn't "read the source". These tests are that + * answer, and they fail the moment a command is added, removed or reworded, so + * the list cannot quietly drift. + * + * Spying at the `execFile` boundary rather than on `lib/npm.ts#execAsync` is + * deliberate: helpers inside npm.ts call `execAsync` through their module-local + * binding, which a spy on the export cannot intercept. An earlier draft of this + * file used that spy and reported the `state` group as spawning nothing, when + * it was in fact shelling out four times. + */ + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, execFile: vi.fn() }; +}); + +const PROXY_VARS = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "NO_PROXY", + "no_proxy", + "NODE_USE_ENV_PROXY", + "NODE_USE_SYSTEM_CA", +]; + +let spawned: string[]; + +beforeEach(() => { + spawned = []; + for (const name of PROXY_VARS) vi.stubEnv(name, ""); + proxy.resetProxyState(); + + vi.mocked(child_process.execFile).mockImplementation((( + ...callArgs: unknown[] + ) => { + const cb = callArgs[callArgs.length - 1] as ( + e: Error | null, + stdout: string, + stderr: string, + ) => void; + const first = callArgs[0] as string; + const second = callArgs[1]; + // POSIX passes (file, args, opts, cb); win32 passes one command string. + spawned.push( + Array.isArray(second) + ? `${first} ${(second as string[]).join(" ")}`.trim() + : first, + ); + setImmediate(() => cb(null, "/tmp/npm-prefix", "")); + return {} as unknown as child_process.ChildProcess; + }) as unknown as typeof child_process.execFile); + + // Nothing below should reach the network or the real OS trust store. + vi.spyOn(log, "loggedFetch").mockResolvedValue( + new Response("", { status: 200 }), + ); + vi.spyOn(tls.tlsApi, "supported").mockReturnValue(true); + vi.spyOn(tls.tlsApi, "getCACertificates").mockReturnValue(["cert"]); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +async function commandsFor(checks: Check[]): Promise { + // Reset so each call reports only its own group — several tests below run + // more than one group and would otherwise see the running total. + spawned = []; + await runChecks(checks, { + accessToken: "token", + apiKey: "key", + gatewayUrl: "https://gateway.example.com", + models: ["m-alpha"], + }); + return spawned; +} + +describe("every command `codevhub doctor` runs", () => { + // The pre-flight is also embedded at the head of `codevhub install`, where a + // single `npm config get` (~300ms) would be felt on every run. Zero is the + // contract, not an accident — see PREFLIGHT_CHECKS in lib/doctor.ts. + test("pre-flight (also used by install): runs nothing", async () => { + expect(await commandsFor(PREFLIGHT_CHECKS)).toEqual([]); + }); + + test("environment group: 7 npm probes, all read-only", async () => { + expect(await commandsFor(ENVIRONMENT_CHECKS)).toEqual([ + "npm -v", + "npm config get prefix", + "npm config get registry", + "npm config get proxy", + "npm config get https-proxy", + "npm config get strict-ssl", + "npm config get cafile", + ]); + }); + + test("network group: a registry ping and a metadata read", async () => { + expect(await commandsFor(NETWORK_CHECKS)).toEqual([ + "npm ping", + "npm view codev-ai version", + ]); + }); + + test("account group: pure HTTP, no child processes", async () => { + expect(await commandsFor(ACCOUNT_CHECKS)).toEqual([]); + }); + + test("llm group: pure HTTP, no child processes", async () => { + expect(await commandsFor(LLM_CHECKS)).toEqual([]); + }); + + // Regression: this ran `npm root -g` once per agent — four serial spawns for + // an answer that cannot differ between them. + test("state group: resolves npm's global root exactly once", async () => { + expect(await commandsFor(STATE_CHECKS)).toEqual(["npm root -g"]); + }); + + test("a whole run spawns 10 processes, and every one is read-only", async () => { + const all = [ + ...(await commandsFor(ENVIRONMENT_CHECKS)), + ...(await commandsFor(NETWORK_CHECKS)), + ...(await commandsFor(ACCOUNT_CHECKS)), + ...(await commandsFor(LLM_CHECKS)), + ...(await commandsFor(STATE_CHECKS)), + ]; + expect(all).toHaveLength(10); + // Everything is `npm`, and nothing mutates: no install, uninstall, publish, + // cache write or config *set*. `doctor` promises to be read-only and this + // is what enforces it. + for (const command of all) { + expect(command).toMatch(/^npm /); + expect(command).not.toMatch( + /\b(install|i|add|uninstall|rm|remove|publish|link|update|dedupe)\b/, + ); + expect(command).not.toMatch(/config set/); + } + }); +}); + +// The one command doctor runs that is NOT npm, and the only one that starts a +// new CoDev process. Its argv and environment are asserted in doctor.test.ts; +// this states the shape alongside the rest of the inventory. +describe("the one non-npm command: the proxy retry", () => { + test("runs node on this CLI again with `doctor`", () => { + const spawn = vi + .spyOn(reexec.spawner, "spawnSync") + // biome-ignore lint/suspicious/noExplicitAny: minimal SpawnSyncReturns stub + .mockReturnValue({ status: 0 } as any); + + rerunDoctorWithProxy( + { http: "http://10.0.0.1:8080", https: "http://10.0.0.1:8080" }, + [], + ); + + const [file, argv] = spawn.mock.calls[0] ?? []; + expect([file, ...(argv as string[])]).toEqual([ + process.execPath, + ...process.execArgv, + process.argv[1], + "doctor", + ]); + }); + + test("is not spawned unless the user supplies a proxy", async () => { + const spawn = vi.spyOn(reexec.spawner, "spawnSync"); + await commandsFor(NETWORK_CHECKS); + expect(spawn).not.toHaveBeenCalled(); + }); +}); From 78b3dcd100ce888e331333c9d1770173e1ce29b2 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Wed, 29 Jul 2026 09:02:58 +0700 Subject: [PATCH 10/16] feat: always write ~/.codev-hub/doctor-report.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `codevhub doctor` produced a screenful of diagnosis and nothing durable. The results did reach the NDJSON diagnostic log, but extracting one run from an append-only trail is not something a user on a support call is going to do. Every run now writes a self-contained JSON report and replaces the previous one — deliberately a single file rather than a dated series, because it is a snapshot of "how is this machine right now" and a stale one is worse than none when it gets attached to a ticket. It carries the timestamp, CoDev/Node/platform versions, the full proxy and TLS environment, every check outcome with its diagnosis, the summary counts, and the suggested next steps. The summary line names the path, tilde- abbreviated so it stays on one line — a wrapped path picks up the Step's `│ ` gutter on continuation lines and is corrupted when copied, the same trap Login.tsx documents for the sign-in URL. Two properties it has to hold. It is scrubbed with the same patterns as the terminal output and the log, because this is the file most likely to be emailed around and so the last place a token should survive; scrubbing after stringification is safe since no pattern can match across a JSON quote or escape. And writing is best-effort in the sense lib/log.ts established — a diagnostic that breaks the command it is diagnosing is worse than no diagnostic — so an unwritable location returns null and the run continues. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 + README.md | 9 ++- src/DoctorApp.tsx | 53 ++++++++++++++-- src/lib/doctor.ts | 99 ++++++++++++++++++++++++++++- src/lib/paths.ts | 8 +++ tests/lib/doctor.test.ts | 133 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 296 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 43989a9..ae1b1ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -196,6 +196,8 @@ Pre-flight for everything `install` depends on, so users on a locked-down networ **The re-exec handoff.** When the network group fails, `DoctorApp` offers a proxy prompt and, on submit, records `doctorOutcome.retryWithProxy` and exits — `index.tsx` calls `rerunDoctorWithProxy` after `waitUntilExit()` resolves. It **cannot** happen inside the component: `spawnSync` with inherited stdio while Ink still owns the TTY corrupts the terminal. `CODEV_DOCTOR_PROXY=1` on the child stops the prompt being offered twice, and is the *only* guard on it. +**The report file.** Every run writes `~/.codev-hub/doctor-report.json` (`paths.ts#doctorReportPath`) and **replaces** the previous one — it is a snapshot of "how is this machine right now", and a stale one is worse than none when it is attached to a ticket. It carries the timestamp, CoDev/Node/platform versions, the full proxy environment, per-check outcomes including diagnoses, the summary counts and the next steps. Writing is best-effort in the same sense as `lib/log.ts`: a diagnostic that breaks the command it is diagnosing is worse than no diagnostic, so a failure returns null and the run continues. The serialized JSON goes through `redactSecrets` before hitting disk — this is the file most likely to be emailed around, so it is the last place a token should survive. Note this is *distinct* from the NDJSON diagnostic log, which also receives every check as a `doctor.check` document; the report is one self-contained artifact, the log is the append-only trail. + **Every child process is inventoried.** `tests/lib/doctor-commands.test.ts` asserts the complete, ordered list of commands each group spawns — 10 for a full run, all `npm`, none mutating — so a new subprocess shows up in review rather than quietly appearing on users' machines. It spies at the **`execFile` boundary**, not on `npm.ts#execAsync`: helpers inside npm.ts call `execAsync` through their module-local binding, which a spy on the export cannot intercept. An earlier draft used that spy and reported the `state` group as spawning nothing while it was in fact shelling out four times. That same measurement is what caught `installedAgentsCheck` running `npm root -g` once per agent; it now resolves the root once. The spawn itself lives in `lib/doctor.ts`, not the dispatcher, so the exact command is assertable — `tests/lib/doctor.test.ts` spells it out rather than describing it. It runs `node [...process.execArgv] doctor [...args]` with `stdio: "inherit"`: node directly on the script, never a shell and never the `codevhub` bin. Forwarding `execArgv` is load-bearing — a `pnpm dev` run carries tsx's loader flags, and a child without them cannot load TypeScript and dies immediately. diff --git a/README.md b/README.md index b01ffd3..b5dc62f 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,13 @@ through a proxy you type in (nothing is written to disk), so you see the fix work before it prints the exact `export` / `setx` commands to make it permanent. Exit code is 0 when nothing failed — warnings do not fail it — and 1 otherwise. -Every result also lands in the diagnostic log, so `codevhub logs` captures a -whole run for a support ticket. + +Every run also writes a machine-readable report to +**`~/.codev-hub/doctor-report.json`**, replacing the previous one. It holds the +full results, diagnoses, your Node and proxy environment, and the suggested next +steps — attach it to a support ticket rather than pasting screenshots. Secrets +are scrubbed before it is written. The same results additionally land in the +diagnostic log, so `codevhub logs` can replay a whole run. ### Working behind a proxy diff --git a/src/DoctorApp.tsx b/src/DoctorApp.tsx index af8e640..74b9f6b 100644 --- a/src/DoctorApp.tsx +++ b/src/DoctorApp.tsx @@ -1,3 +1,4 @@ +import { homedir } from "node:os"; import { Box, Text, useApp } from "ink"; import { useCallback, useEffect, useRef, useState } from "react"; import { Banner } from "@/components/Banner.js"; @@ -11,6 +12,7 @@ import { SSO_URL } from "@/lib/const.js"; import { ACCOUNT_CHECKS, alreadyRetriedWithProxy, + buildDoctorReport, buildNextSteps, type Check, type CheckGroup, @@ -24,6 +26,7 @@ import { NETWORK_CHECKS, runChecks, STATE_CHECKS, + writeDoctorReport, } from "@/lib/doctor.js"; import { logDebug } from "@/lib/log.js"; import { readProxyEnv } from "@/lib/proxy.js"; @@ -106,6 +109,9 @@ export function DoctorApp({ force = false }: DoctorAppProps) { // summary and the exit code, so it gets an outcome of its own. const [loginOutcome, setLoginOutcome] = useState(null); const [proxyUrl, setProxyUrl] = useState(null); + // Where the machine-readable report landed, so the summary can point at it. + // null when the write failed — best-effort, never fatal. + const [reportPath, setReportPath] = useState(null); const didPrepRef = useRef(false); // Checks mutate the context as they resolve (token → key → gateway URL → // models), so it must be a ref: a state read would see a stale snapshot @@ -213,8 +219,8 @@ export function DoctorApp({ force = false }: DoctorAppProps) { setPhase("account"); }, []); - // Terminal phase: compute the exit code, then hold the frame briefly so Ink - // flushes the summary before unmounting. + // Terminal phase: write the report, compute the exit code, then hold the + // frame briefly so Ink flushes the summary before unmounting. useEffect(() => { if (phase !== "done") return; const all = [ @@ -225,9 +231,20 @@ export function DoctorApp({ force = false }: DoctorAppProps) { ...outcomes.llm, ]; doctorOutcome.exitCode = hasFailure(all) ? 1 : 0; + // The `state` group is informational, but it belongs in the file — it is + // what tells support what was already on the machine. + setReportPath( + writeDoctorReport( + buildDoctorReport( + [...all, ...outcomes.state], + new Date().toISOString(), + proxyUrl ? { http: proxyUrl, https: proxyUrl } : undefined, + ), + ), + ); const timer = setTimeout(() => exit(), 1000); return () => clearTimeout(timer); - }, [phase, outcomes, loginOutcome, exit]); + }, [phase, outcomes, loginOutcome, proxyUrl, exit]); const groupProps = (group: CheckGroup) => { const done = outcomes[group]; @@ -327,7 +344,11 @@ export function DoctorApp({ force = false }: DoctorAppProps) { {phase === "done" && ( Result}> - + )} @@ -335,12 +356,24 @@ export function DoctorApp({ force = false }: DoctorAppProps) { ); } +/** + * `/Users/minh/.codev-hub/x` → `~/.codev-hub/x`. Shorter (so it does not wrap) + * and directly usable in a shell — both zsh/bash and PowerShell expand `~`. + * Returns the path untouched when it is not under the home directory. + */ +function abbreviateHome(path: string): string { + const home = homedir(); + return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path; +} + function Summary({ outcomes, nextSteps, + reportPath, }: { outcomes: CheckOutcome[]; nextSteps: string[]; + reportPath: string | null; }) { const failed = outcomes.filter((o) => o.status === "fail").length; const warned = outcomes.filter((o) => o.status === "warn").length; @@ -377,6 +410,18 @@ function Summary({ ))} )} + {/* Naming the file is the point of writing it — an artifact nobody + knows about cannot help a support conversation. Abbreviated to `~` + so it stays on one line: a path that wraps picks up the Step's + `│ ` gutter on continuation lines, which corrupts it when copied + (the same trap Login.tsx documents for the sign-in URL). */} + {reportPath && ( + + + {`Full report saved to ${abbreviateHome(reportPath)} — attach it to a support ticket.`} + + + )} ); } diff --git a/src/lib/doctor.ts b/src/lib/doctor.ts index 5ccb095..5fd41ce 100644 --- a/src/lib/doctor.ts +++ b/src/lib/doctor.ts @@ -1,5 +1,11 @@ -import { accessSync, existsSync, constants as fsConstants } from "node:fs"; -import { delimiter, join } from "node:path"; +import { + accessSync, + existsSync, + constants as fsConstants, + mkdirSync, + writeFileSync, +} from "node:fs"; +import { delimiter, dirname, join } from "node:path"; import { loadApiKey } from "@/lib/auth.js"; import { fetchApiKey, @@ -22,6 +28,7 @@ import { nodeVersionMeets, parseNodeVersion, RECOMMENDED_NODE, + VERSION, } from "@/lib/const.js"; import { currentTraceId, @@ -33,6 +40,7 @@ import { redactSecrets, } from "@/lib/log.js"; import { CLI, execAsync, type NpmTool, npmGlobalRoot, PKG } from "@/lib/npm.js"; +import { doctorReportPath } from "@/lib/paths.js"; import { backendHost, hasProxyConfigured, @@ -1640,6 +1648,93 @@ export function buildNextSteps( return lines; } +// --------------------------------------------------------------------------- +// Report file +// --------------------------------------------------------------------------- + +export interface DoctorReport { + generatedAt: string; + codevVersion: string; + node: { version: string; platform: string; arch: string }; + proxy: ProxyEnv & { autoEnabledByCodev: boolean }; + summary: { + ok: boolean; + passed: number; + warned: number; + failed: number; + skipped: number; + }; + checks: CheckOutcome[]; + nextSteps: string[]; +} + +export function buildDoctorReport( + outcomes: CheckOutcome[], + generatedAt: string, + proxyUsed?: { http?: string; https?: string }, +): DoctorReport { + const count = (s: CheckStatus) => + outcomes.filter((o) => o.status === s).length; + return { + generatedAt, + codevVersion: VERSION, + node: { + version: process.version, + platform: process.platform, + arch: process.arch, + }, + proxy: { ...readProxyEnv(), autoEnabledByCodev: proxyAutoEnabled() }, + summary: { + ok: !hasFailure(outcomes), + passed: count("pass"), + warned: count("warn"), + failed: count("fail"), + skipped: count("skip"), + }, + checks: outcomes, + nextSteps: buildNextSteps(outcomes, proxyUsed), + }; +} + +/** + * Write the machine-readable report to ~/.codev-hub/doctor-report.json, + * replacing any previous one. + * + * Best-effort by construction, matching the logging discipline in lib/log.ts: + * a diagnostic that breaks the command it is diagnosing is worse than no + * diagnostic. Returns the path on success, null if anything went wrong. + * + * The serialized JSON goes through the same secret scrubbing as the terminal + * output and the NDJSON log — this file exists to be attached to tickets, so + * it is the *last* place a token should survive. Scrubbing after + * stringification is safe: every replacement is plain text and none of the + * patterns can match across a JSON quote or escape. + */ +export function writeDoctorReport(report: DoctorReport): string | null { + try { + const path = doctorReportPath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync( + path, + `${redactSecrets(JSON.stringify(report, null, 2))}\n`, + "utf-8", + ); + logInfo("wrote doctor report", { + action: "doctor.report", + outcome: "success", + extra: { checks: report.checks.length, ok: report.summary.ok }, + }); + return path; + } catch (err) { + logWarn("could not write the doctor report", { + action: "doctor.report", + outcome: "failure", + err, + }); + return null; + } +} + // --------------------------------------------------------------------------- // Re-exec handoff // diff --git a/src/lib/paths.ts b/src/lib/paths.ts index c494b5f..bbde373 100644 --- a/src/lib/paths.ts +++ b/src/lib/paths.ts @@ -17,6 +17,14 @@ export function cliLogsDir(): string { return join(homedir(), ".codev-hub", "logs"); } +// The machine-readable result of the last `codevhub doctor` run. Deliberately a +// single file rather than a dated series: it is a snapshot of "how is this +// machine right now", and a stale one is worse than none when someone attaches +// it to a support ticket. Every run replaces it. +export function doctorReportPath(): string { + return join(homedir(), ".codev-hub", "doctor-report.json"); +} + // Maps a working directory to a per-project subfolder name. Strips the user's // home prefix so the folder is shorter, replaces non-alphanumeric chars with // dashes, then collapses runs of dashes and trims them. Falls back to "home" diff --git a/tests/lib/doctor.test.ts b/tests/lib/doctor.test.ts index e3c16de..d34a306 100644 --- a/tests/lib/doctor.test.ts +++ b/tests/lib/doctor.test.ts @@ -1,9 +1,21 @@ +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import * as backend from "@/lib/backend.js"; +import { VERSION } from "@/lib/const.js"; import { + buildDoctorReport, buildNextSteps, type CheckOutcome, DOCTOR_PROXY_ENV, + type DoctorReport, describeFailure, diagnoseError, diagnoseExec, @@ -20,6 +32,7 @@ import { renderDiagnosisCompact, rerunDoctorWithProxy, runChecks, + writeDoctorReport, } from "@/lib/doctor.js"; import * as log from "@/lib/log.js"; import * as npm from "@/lib/npm.js"; @@ -967,6 +980,126 @@ describe("remediation output", () => { }); }); +describe("the doctor report file", () => { + let home: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "codev-report-")); + vi.stubEnv("HOME", home); + vi.stubEnv("USERPROFILE", home); + }); + + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + const outcomes: CheckOutcome[] = [ + { + key: "node-version", + label: "Node.js version", + group: "environment", + status: "pass", + detail: "Node 24.15.0.", + }, + { + key: "backend-reach", + label: "Reach the CoDev backend", + group: "network", + status: "fail", + detail: "Timed out.", + fix: "Set HTTP_PROXY, HTTPS_PROXY and NODE_USE_ENV_PROXY=1.", + }, + ]; + + function write(at = "2026-07-29T00:00:00.000Z") { + return writeDoctorReport(buildDoctorReport(outcomes, at)); + } + + test("lands at ~/.codev-hub/doctor-report.json", () => { + const path = write(); + expect(path).toBe(join(home, ".codev-hub", "doctor-report.json")); + expect(existsSync(path as string)).toBe(true); + }); + + test("creates ~/.codev-hub when the machine has never run CoDev", () => { + // The audience for `doctor` is precisely someone who has not installed + // yet, so the directory usually does not exist. + expect(existsSync(join(home, ".codev-hub"))).toBe(false); + expect(write()).not.toBeNull(); + }); + + test("records the results, the summary and the environment", () => { + const report = JSON.parse( + readFileSync(write() as string, "utf-8"), + ) as DoctorReport; + expect(report.summary).toMatchObject({ + ok: false, + passed: 1, + failed: 1, + warned: 0, + skipped: 0, + }); + expect(report.checks).toHaveLength(2); + expect(report.checks[1]?.fix).toContain("NODE_USE_ENV_PROXY"); + expect(report.nextSteps.join("\n")).toContain("codevhub install"); + expect(report.node.version).toBe(process.version); + expect(report.codevVersion).toBe(VERSION); + expect(report.generatedAt).toBe("2026-07-29T00:00:00.000Z"); + }); + + // A stale report is worse than none when someone attaches it to a ticket. + test("every run replaces the previous report", () => { + write("2026-07-29T00:00:00.000Z"); + const path = write("2026-07-30T11:22:33.000Z") as string; + const raw = readFileSync(path, "utf-8"); + expect(raw).toContain("2026-07-30T11:22:33.000Z"); + expect(raw).not.toContain("2026-07-29T00:00:00.000Z"); + // Replaced, not appended — it must still be a single JSON document. + expect(() => JSON.parse(raw)).not.toThrow(); + }); + + // This file exists to be attached to tickets, so it is the last place a + // credential should survive. + test("scrubs credentials before writing", () => { + const path = writeDoctorReport( + buildDoctorReport( + [ + { + key: "api-key", + label: "Fetch a gateway API key", + group: "account", + status: "fail", + detail: "rejected: Authorization: Bearer abc123def456ghi", + diagnosis: { + what: "w", + cause: "c", + fix: "f", + context: [], + raw: ["token sk-livesecretvalue123 leaked"], + }, + }, + ], + "2026-07-29T00:00:00.000Z", + ), + ) as string; + const raw = readFileSync(path, "utf-8"); + expect(raw).not.toContain("abc123def456ghi"); + expect(raw).not.toContain("sk-livesecretvalue123"); + expect(raw).toContain("[REDACTED"); + // Scrubbing must not corrupt the JSON. + expect(() => JSON.parse(raw)).not.toThrow(); + }); + + // Matches the discipline in lib/log.ts: a diagnostic that breaks the command + // it is diagnosing is worse than no diagnostic. + test("an unwritable location is survivable, not fatal", () => { + // A plain file where ~/.codev-hub must be a directory: mkdirSync cannot + // proceed, which is the realistic shape of "we could not write here". + writeFileSync(join(home, ".codev-hub"), "not a directory"); + expect(write()).toBeNull(); + }); +}); + // The retry is the one place `doctor` shells out, and what it runs has to be // exactly reproducible by hand for support. These spell the command out. describe("the proxy retry command", () => { From cb5ba0dfbb0f630b2bab1213c1e4da7ebf3ab0aa Mon Sep 17 00:00:00 2001 From: minhn4 Date: Wed, 29 Jul 2026 09:10:47 +0700 Subject: [PATCH 11/16] feat: show the commands `codevhub doctor` actually ran MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The command inventory existed only in tests, so the answer to "what did this just run on my machine?" was still "read the source" — a fair question for a diagnostic tool people run on locked-down boxes, often while on a call with IT. `doctor` now records every child process it spawns and prints them: ◇ Commands run │ ✓ npm -v (97ms) │ ✓ npm config get prefix (343ms) │ ... │ ✓ npm root -g (79ms) │ All read-only — doctor never installs, uninstalls or changes │ configuration. with duration and outcome per command. The report file carries the same list under `commands`, so an attached report answers it too. The recorder sits inside `execAsync` rather than wrapping it from doctor.ts: helpers in npm.ts (`npmGlobalRoot`, `verifyInstall`, …) call `execAsync` through their module-local binding, which no external wrapper can see — the same property that made an earlier test miss four `npm root -g` spawns entirely. It is off by default so `update` and the upload daemon, which share the seam, do not accumulate an unbounded buffer. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 + src/DoctorApp.tsx | 34 +++++++++++++ src/lib/doctor.ts | 29 ++++++++++- src/lib/npm.ts | 35 +++++++++++++ tests/lib/doctor-commands.test.ts | 82 +++++++++++++++++++++++++++++++ 5 files changed, 181 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index ae1b1ba..24afd1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,6 +198,8 @@ Pre-flight for everything `install` depends on, so users on a locked-down networ **The report file.** Every run writes `~/.codev-hub/doctor-report.json` (`paths.ts#doctorReportPath`) and **replaces** the previous one — it is a snapshot of "how is this machine right now", and a stale one is worse than none when it is attached to a ticket. It carries the timestamp, CoDev/Node/platform versions, the full proxy environment, per-check outcomes including diagnoses, the summary counts and the next steps. Writing is best-effort in the same sense as `lib/log.ts`: a diagnostic that breaks the command it is diagnosing is worse than no diagnostic, so a failure returns null and the run continues. The serialized JSON goes through `redactSecrets` before hitting disk — this is the file most likely to be emailed around, so it is the last place a token should survive. Note this is *distinct* from the NDJSON diagnostic log, which also receives every check as a `doctor.check` document; the report is one self-contained artifact, the log is the append-only trail. +**Every child process is recorded and shown.** `execAsync` carries an opt-in recorder (`npm.ts#commandLog`), which `doctor` switches on for its run. The terminal prints a **Commands run** section — each command with its duration and outcome — and the report file carries the same list under `commands`. The recorder lives *inside* `execAsync` rather than wrapping it from doctor.ts because helpers in npm.ts (`npmGlobalRoot`, `verifyInstall`, …) call it through their module-local binding, which no external wrapper can intercept. It stays off by default so `update` and the upload daemon, which share the same seam, don't accumulate an unbounded buffer. + **Every child process is inventoried.** `tests/lib/doctor-commands.test.ts` asserts the complete, ordered list of commands each group spawns — 10 for a full run, all `npm`, none mutating — so a new subprocess shows up in review rather than quietly appearing on users' machines. It spies at the **`execFile` boundary**, not on `npm.ts#execAsync`: helpers inside npm.ts call `execAsync` through their module-local binding, which a spy on the export cannot intercept. An earlier draft used that spy and reported the `state` group as spawning nothing while it was in fact shelling out four times. That same measurement is what caught `installedAgentsCheck` running `npm root -g` once per agent; it now resolves the root once. The spawn itself lives in `lib/doctor.ts`, not the dispatcher, so the exact command is assertable — `tests/lib/doctor.test.ts` spells it out rather than describing it. It runs `node [...process.execArgv] doctor [...args]` with `stdio: "inherit"`: node directly on the script, never a shell and never the `codevhub` bin. Forwarding `execArgv` is load-bearing — a `pnpm dev` run carries tsx's loader flags, and a child without them cannot load TypeScript and dies immediately. diff --git a/src/DoctorApp.tsx b/src/DoctorApp.tsx index 74b9f6b..209f1e3 100644 --- a/src/DoctorApp.tsx +++ b/src/DoctorApp.tsx @@ -24,11 +24,14 @@ import { hasFailure, LLM_CHECKS, NETWORK_CHECKS, + recordedCommands, runChecks, STATE_CHECKS, + startCommandRecording, writeDoctorReport, } from "@/lib/doctor.js"; import { logDebug } from "@/lib/log.js"; +import type { CommandRecord } from "@/lib/npm.js"; import { readProxyEnv } from "@/lib/proxy.js"; type Phase = @@ -112,6 +115,9 @@ export function DoctorApp({ force = false }: DoctorAppProps) { // Where the machine-readable report landed, so the summary can point at it. // null when the write failed — best-effort, never fatal. const [reportPath, setReportPath] = useState(null); + // Snapshotted at the terminal phase — the recorder is a mutable buffer, so + // reading it during render would tear as checks are still finishing. + const [commands, setCommands] = useState([]); const didPrepRef = useRef(false); // Checks mutate the context as they resolve (token → key → gateway URL → // models), so it must be a ref: a state read would see a stale snapshot @@ -126,6 +132,12 @@ export function DoctorApp({ force = false }: DoctorAppProps) { }); }, [phase]); + // Start recording before any check runs, so the "Commands run" section can + // show the user exactly what was executed on their machine. + useEffect(() => { + startCommandRecording(); + }, []); + // --force wipes the cached session first, so `sso-login` measures a real // round trip rather than reporting the cache. Same approach as LoginApp. useEffect(() => { @@ -233,6 +245,7 @@ export function DoctorApp({ force = false }: DoctorAppProps) { doctorOutcome.exitCode = hasFailure(all) ? 1 : 0; // The `state` group is informational, but it belongs in the file — it is // what tells support what was already on the machine. + setCommands(recordedCommands()); setReportPath( writeDoctorReport( buildDoctorReport( @@ -342,6 +355,27 @@ export function DoctorApp({ force = false }: DoctorAppProps) { ), )} + {/* `doctor` runs things on someone else's machine, often a + locked-down one. What it ran should be answerable from the + output, not from the source. */} + {phase === "done" && commands.length > 0 && ( + Commands run}> + + {commands.map((c, i) => ( + + {c.ok ? "✓" : "✗"} + {` ${c.command}`} + {` (${c.durationMs}ms)`} + + ))} + + { + "All read-only — doctor never installs, uninstalls or changes configuration." + } + + + + )} {phase === "done" && ( Result}> { // Every child process codev shells out to funnels through here (npm, the // agent --version probes, `code --install-extension`, JetBrains CLIs, @@ -80,6 +108,13 @@ export function execAsync(file: string, args: string[]): Promise { stderr: string, ) => { const durationMs = Date.now() - startedAt; + if (commandLog.enabled) { + commandLog.entries.push({ + command: `${file} ${args.join(" ")}`.trim(), + durationMs, + ok: !error, + }); + } if (error) { logWarn(`exec failed: ${file} ${args.join(" ")}`, { action: "process.exit", diff --git a/tests/lib/doctor-commands.test.ts b/tests/lib/doctor-commands.test.ts index cb2d4ea..1c19c88 100644 --- a/tests/lib/doctor-commands.test.ts +++ b/tests/lib/doctor-commands.test.ts @@ -2,16 +2,20 @@ import * as child_process from "node:child_process"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { ACCOUNT_CHECKS, + buildDoctorReport, type Check, ENVIRONMENT_CHECKS, LLM_CHECKS, NETWORK_CHECKS, PREFLIGHT_CHECKS, + recordedCommands, rerunDoctorWithProxy, runChecks, STATE_CHECKS, + startCommandRecording, } from "@/lib/doctor.js"; import * as log from "@/lib/log.js"; +import { commandLog } from "@/lib/npm.js"; import * as proxy from "@/lib/proxy.js"; import * as reexec from "@/lib/reexec.js"; import * as tls from "@/lib/tls.js"; @@ -52,6 +56,8 @@ let spawned: string[]; beforeEach(() => { spawned = []; + commandLog.enabled = false; + commandLog.entries = []; for (const name of PROXY_VARS) vi.stubEnv(name, ""); proxy.resetProxyState(); @@ -164,6 +170,82 @@ describe("every command `codevhub doctor` runs", () => { }); }); +// Knowing the list is only useful if the user is shown it. The inventory above +// is the contract; this is the part that gets it in front of them. +describe("the commands are recorded and reported back", () => { + test("recording is off until doctor asks for it", async () => { + commandLog.enabled = false; + commandLog.entries = []; + await commandsFor(ENVIRONMENT_CHECKS); + // Other commands (`update`, the upload daemon) share execAsync and must + // not accumulate an unbounded buffer. + expect(commandLog.entries).toEqual([]); + }); + + test("captures every command, in order, with timing and outcome", async () => { + startCommandRecording(); + await commandsFor(NETWORK_CHECKS); + const recorded = recordedCommands(); + expect(recorded.map((c) => c.command)).toEqual([ + "npm ping", + "npm view codev-ai version", + ]); + for (const c of recorded) { + expect(c.ok).toBe(true); + expect(typeof c.durationMs).toBe("number"); + } + }); + + // It catches `npm root -g`, which is spawned by a helper *inside* npm.ts — + // the case an external wrapper around execAsync cannot see. + test("captures commands spawned from inside npm.ts itself", async () => { + startCommandRecording(); + await commandsFor(STATE_CHECKS); + expect(recordedCommands().map((c) => c.command)).toEqual(["npm root -g"]); + }); + + test("a failing command is recorded as failed, not dropped", async () => { + vi.mocked(child_process.execFile).mockImplementation((( + ...callArgs: unknown[] + ) => { + const cb = callArgs[callArgs.length - 1] as ( + e: Error | null, + o: string, + s: string, + ) => void; + setImmediate(() => cb(new Error("boom"), "", "npm ERR!")); + return {} as unknown as child_process.ChildProcess; + }) as unknown as typeof child_process.execFile); + + startCommandRecording(); + await commandsFor(NETWORK_CHECKS); + expect(recordedCommands()[0]?.ok).toBe(false); + }); + + test("a new run starts from an empty list", async () => { + startCommandRecording(); + await commandsFor(NETWORK_CHECKS); + expect(recordedCommands()).toHaveLength(2); + startCommandRecording(); + expect(recordedCommands()).toEqual([]); + }); + + test("the report file carries them too", async () => { + startCommandRecording(); + await commandsFor(ENVIRONMENT_CHECKS); + const report = buildDoctorReport([], "2026-07-29T00:00:00.000Z"); + expect(report.commands.map((c) => c.command)).toEqual([ + "npm -v", + "npm config get prefix", + "npm config get registry", + "npm config get proxy", + "npm config get https-proxy", + "npm config get strict-ssl", + "npm config get cafile", + ]); + }); +}); + // The one command doctor runs that is NOT npm, and the only one that starts a // new CoDev process. Its argv and environment are asserted in doctor.test.ts; // this states the shape alongside the rest of the inventory. From 8825b390d31ffd5cd5693f49803b05a794743776 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Wed, 29 Jul 2026 09:22:04 +0700 Subject: [PATCH 12/16] feat: show the endpoints doctor contacted, not just the npm commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Commands run" listed only subprocesses, so it answered half the question. The connection tests — codev-backend, Supabase, the gateway, SSO — are HTTP, never touch execAsync, and appeared nowhere. On a corporate network they are the more useful half: the endpoints are what IT needs in order to allow-list them. `loggedFetch` gains an opt-in recorder, the sibling of npm.ts#commandLog, and doctor renders a second section: ◇ Endpoints contacted │ ✓ POST https://netmind.viettel.vn/codev-backend/config (401, 96ms) │ ✓ POST https://netmind.viettel.vn/codev-backend/auth/exchange (401, 248ms) │ ... │ Reachability only — a 401 here means the endpoint answered. Query │ strings are omitted; they can carry tokens. The report file carries the same list under `requests`. Two things the first attempt got wrong, both caught by running it: Scoring on `res.ok` painted the expected 401s red, directly contradicting the check rows above that correctly call them a pass. This section answers "was the endpoint reachable", so any response counts and only "no response at all" is a failure — recorded as a null status. And each row was three flex children, so a long URL wrapped and knocked the status icon out of its column. Both sections now render one Text per row. Query strings are dropped rather than redacted: OAuth codes and signed-URL signatures live there and none of it helps a user see which endpoint was contacted. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 +- src/DoctorApp.tsx | 54 ++++++++++++++--- src/lib/doctor.ts | 11 ++++ src/lib/log.ts | 66 ++++++++++++++++++++- tests/lib/doctor-commands.test.ts | 97 +++++++++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 24afd1b..2902c11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,7 +198,7 @@ Pre-flight for everything `install` depends on, so users on a locked-down networ **The report file.** Every run writes `~/.codev-hub/doctor-report.json` (`paths.ts#doctorReportPath`) and **replaces** the previous one — it is a snapshot of "how is this machine right now", and a stale one is worse than none when it is attached to a ticket. It carries the timestamp, CoDev/Node/platform versions, the full proxy environment, per-check outcomes including diagnoses, the summary counts and the next steps. Writing is best-effort in the same sense as `lib/log.ts`: a diagnostic that breaks the command it is diagnosing is worse than no diagnostic, so a failure returns null and the run continues. The serialized JSON goes through `redactSecrets` before hitting disk — this is the file most likely to be emailed around, so it is the last place a token should survive. Note this is *distinct* from the NDJSON diagnostic log, which also receives every check as a `doctor.check` document; the report is one self-contained artifact, the log is the append-only trail. -**Every child process is recorded and shown.** `execAsync` carries an opt-in recorder (`npm.ts#commandLog`), which `doctor` switches on for its run. The terminal prints a **Commands run** section — each command with its duration and outcome — and the report file carries the same list under `commands`. The recorder lives *inside* `execAsync` rather than wrapping it from doctor.ts because helpers in npm.ts (`npmGlobalRoot`, `verifyInstall`, …) call it through their module-local binding, which no external wrapper can intercept. It stays off by default so `update` and the upload daemon, which share the same seam, don't accumulate an unbounded buffer. +**Everything doctor does is recorded and shown — both halves.** Subprocesses go through an opt-in recorder in `execAsync` (`npm.ts#commandLog`); HTTP requests go through its sibling in `loggedFetch` (`log.ts#requestLog`). `doctor` switches both on for its run and renders **Commands run** and **Endpoints contacted**, with the same two lists in the report file under `commands` and `requests`. The npm list alone was only half the answer: the connection tests to the backend, Supabase and the gateway never touch `execAsync`, and on a corporate network the endpoints are the *more* useful half — they are what IT allow-lists. Recorded URLs drop the query string (OAuth codes, signed-URL signatures). The endpoints section scores on **reachability, not 2xx**: a 401 means the endpoint answered, and scoring it on `ok` painted expected 401s red while the check rows above correctly called them a pass. Each recorder lives *inside* its seam rather than wrapping it, because callers reach those functions through module-local bindings no external wrapper can intercept — and both stay off by default so `update` and the upload daemon don't accumulate unbounded buffers. `execAsync` carries an opt-in recorder (`npm.ts#commandLog`), which `doctor` switches on for its run. The terminal prints a **Commands run** section — each command with its duration and outcome — and the report file carries the same list under `commands`. The recorder lives *inside* `execAsync` rather than wrapping it from doctor.ts because helpers in npm.ts (`npmGlobalRoot`, `verifyInstall`, …) call it through their module-local binding, which no external wrapper can intercept. It stays off by default so `update` and the upload daemon, which share the same seam, don't accumulate an unbounded buffer. **Every child process is inventoried.** `tests/lib/doctor-commands.test.ts` asserts the complete, ordered list of commands each group spawns — 10 for a full run, all `npm`, none mutating — so a new subprocess shows up in review rather than quietly appearing on users' machines. It spies at the **`execFile` boundary**, not on `npm.ts#execAsync`: helpers inside npm.ts call `execAsync` through their module-local binding, which a spy on the export cannot intercept. An earlier draft used that spy and reported the `state` group as spawning nothing while it was in fact shelling out four times. That same measurement is what caught `installedAgentsCheck` running `npm root -g` once per agent; it now resolves the root once. diff --git a/src/DoctorApp.tsx b/src/DoctorApp.tsx index 209f1e3..1bbe443 100644 --- a/src/DoctorApp.tsx +++ b/src/DoctorApp.tsx @@ -25,12 +25,13 @@ import { LLM_CHECKS, NETWORK_CHECKS, recordedCommands, + recordedRequests, runChecks, STATE_CHECKS, startCommandRecording, writeDoctorReport, } from "@/lib/doctor.js"; -import { logDebug } from "@/lib/log.js"; +import { logDebug, type RequestRecord } from "@/lib/log.js"; import type { CommandRecord } from "@/lib/npm.js"; import { readProxyEnv } from "@/lib/proxy.js"; @@ -118,6 +119,7 @@ export function DoctorApp({ force = false }: DoctorAppProps) { // Snapshotted at the terminal phase — the recorder is a mutable buffer, so // reading it during render would tear as checks are still finishing. const [commands, setCommands] = useState([]); + const [requests, setRequests] = useState([]); const didPrepRef = useRef(false); // Checks mutate the context as they resolve (token → key → gateway URL → // models), so it must be a ref: a state read would see a stale snapshot @@ -246,6 +248,7 @@ export function DoctorApp({ force = false }: DoctorAppProps) { // The `state` group is informational, but it belongs in the file — it is // what tells support what was already on the machine. setCommands(recordedCommands()); + setRequests(recordedRequests()); setReportPath( writeDoctorReport( buildDoctorReport( @@ -357,16 +360,21 @@ export function DoctorApp({ force = false }: DoctorAppProps) { {/* `doctor` runs things on someone else's machine, often a locked-down one. What it ran should be answerable from the - output, not from the source. */} + output, not from the source. Both halves matter: the + subprocesses, and — on a corporate network, more usefully — + the endpoints, which are what IT needs to allow-list. */} {phase === "done" && commands.length > 0 && ( Commands run}> + {/* One Text per row for the same reason as the endpoints + below: a wrapped row must not shift the status column. */} {commands.map((c, i) => ( - - {c.ok ? "✓" : "✗"} - {` ${c.command}`} - {` (${c.durationMs}ms)`} - + + {`${c.ok ? "✓" : "✗"} ${c.command} (${c.durationMs}ms)`} + ))} { @@ -376,6 +384,38 @@ export function DoctorApp({ force = false }: DoctorAppProps) { )} + {phase === "done" && requests.length > 0 && ( + Endpoints contacted}> + + {requests.map((r, i) => { + // This section answers "was the endpoint reachable?", so ANY + // response counts — a 401 means we got there and were told + // no, which is a success at this layer. Scoring on `ok` + // (2xx) marked the expected 401s red and contradicted the + // check rows above, which correctly call them a pass. Only + // "no response at all" is a failure here. + const reached = r.status !== null; + // One Text, not three: a long URL wrapping across separate + // flex children knocks the status icon out of its column. + return ( + + {`${reached ? "✓" : "✗"} ${r.method} ${r.url} (${ + r.status ?? "no response" + }, ${r.durationMs}ms)`} + + ); + })} + + { + "Reachability only — a 401 here means the endpoint answered. Query strings are omitted; they can carry tokens." + } + + + + )} {phase === "done" && ( Result}> { // bearer tokens; error response bodies are captured from a clone, truncated, // and pass through the same line scrubbing as every other document. With // logging disabled this is a plain fetch passthrough. +export interface RequestRecord { + method: string; + /** Scheme, host and path. The query is stripped — it can carry tokens. */ + url: string; + /** null when no response ever arrived (DNS/TCP/TLS failure). */ + status: number | null; + durationMs: number; + ok: boolean; +} + +/** + * Opt-in record of every HTTP request made through `loggedFetch`. + * + * The sibling of `npm.ts#commandLog`, for the same reason: `codevhub doctor` + * shows the user everything it did on their machine, and on a corporate + * network the endpoints it contacted are the *more* useful half — they are + * what IT needs in order to allow-list them. + * + * Off by default; only `doctor` turns it on. + */ +export const requestLog: { enabled: boolean; entries: RequestRecord[] } = { + enabled: false, + entries: [], +}; + +export function recordRequests(): void { + requestLog.enabled = true; + requestLog.entries = []; +} + +// Query strings are dropped rather than redacted: OAuth codes, signed-URL +// signatures and access tokens all live there, and none of it helps a user +// understand which endpoint was contacted. +function requestUrl(raw: string): string { + try { + const u = new URL(raw); + return `${u.origin}${u.pathname}`; + } catch { + const q = raw.indexOf("?"); + return q === -1 ? raw : raw.slice(0, q); + } +} + +function recordRequest(record: RequestRecord): void { + if (requestLog.enabled) requestLog.entries.push(record); +} + export async function loggedFetch( endpoint: string, input: string | URL, @@ -312,6 +359,13 @@ export async function loggedFetch( try { const res = await fetchTrustingSystemCa(input, init, endpoint); const durationMs = Date.now() - startedAt; + recordRequest({ + method, + url: requestUrl(url), + status: res.status, + durationMs, + ok: res.ok, + }); if (res.ok) { logDebug(`http ${method} ${endpoint} → ${res.status}`, { action: "http.request", @@ -339,13 +393,23 @@ export async function loggedFetch( } return res; } catch (err) { + const durationMs = Date.now() - startedAt; + // No response at all — DNS, TCP or TLS. Recorded with a null status so + // the user can see the request was attempted and got nowhere. + recordRequest({ + method, + url: requestUrl(url), + status: null, + durationMs, + ok: false, + }); logError(`http ${method} ${endpoint} failed`, { action: "http.request", eventType: "end", outcome: "failure", url, method, - durationMs: Date.now() - startedAt, + durationMs, err, extra: { endpoint }, }); diff --git a/tests/lib/doctor-commands.test.ts b/tests/lib/doctor-commands.test.ts index 1c19c88..c343e0d 100644 --- a/tests/lib/doctor-commands.test.ts +++ b/tests/lib/doctor-commands.test.ts @@ -9,12 +9,14 @@ import { NETWORK_CHECKS, PREFLIGHT_CHECKS, recordedCommands, + recordedRequests, rerunDoctorWithProxy, runChecks, STATE_CHECKS, startCommandRecording, } from "@/lib/doctor.js"; import * as log from "@/lib/log.js"; +import { loggedFetch, recordRequests, requestLog } from "@/lib/log.js"; import { commandLog } from "@/lib/npm.js"; import * as proxy from "@/lib/proxy.js"; import * as reexec from "@/lib/reexec.js"; @@ -276,3 +278,98 @@ describe("the one non-npm command: the proxy retry", () => { expect(spawn).not.toHaveBeenCalled(); }); }); + +/** + * The npm list alone was only half the answer. `doctor`'s connection tests — + * backend, Supabase, the gateway — are HTTP, never touch `execAsync`, and so + * appeared nowhere. On a corporate network they are the *more* useful half: + * the endpoints are what IT needs in order to allow-list them. + * + * These stub global `fetch`, not `loggedFetch`: the recorder lives inside + * `loggedFetch`, so stubbing that export bypasses the very code under test. + */ +describe("the endpoints doctor contacted", () => { + beforeEach(() => { + // Undo the module-level loggedFetch stub so the real one — and its + // recorder — actually runs. + vi.restoreAllMocks(); + recordRequests(); + }); + + function stubFetch(status = 200) { + return vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("", { status })); + } + + test("records method, URL, status and duration", async () => { + stubFetch(200); + await loggedFetch("probe", "https://api.example.com/v1/models", { + method: "GET", + }); + const [r] = recordedRequests(); + expect(r?.method).toBe("GET"); + expect(r?.url).toBe("https://api.example.com/v1/models"); + expect(r?.status).toBe(200); + expect(typeof r?.durationMs).toBe("number"); + }); + + // OAuth codes, signed-URL signatures and access tokens all live in query + // strings, and none of it helps a user see which endpoint was contacted. + test("drops the query string", async () => { + stubFetch(200); + await loggedFetch( + "probe", + "https://api.example.com/callback?code=SUPERSECRET&state=abc", + ); + const [r] = recordedRequests(); + expect(r?.url).toBe("https://api.example.com/callback"); + expect(r?.url).not.toContain("SUPERSECRET"); + }); + + // A 401 means the endpoint answered — reachability succeeded even though + // the request did not. It is recorded, so the UI can score it on + // "did anything come back" rather than on 2xx. + test("records a non-2xx response with its status", async () => { + stubFetch(401); + await loggedFetch("probe", "https://api.example.com/config", { + method: "POST", + }); + const [r] = recordedRequests(); + expect(r?.status).toBe(401); + expect(r?.ok).toBe(false); + }); + + test("a request that never got a response has a null status", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue( + Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("getaddrinfo ENOTFOUND x"), { + code: "ENOTFOUND", + }), + }), + ); + await expect( + loggedFetch("probe", "https://api.example.com/x"), + ).rejects.toThrow(); + const [r] = recordedRequests(); + expect(r?.status).toBeNull(); + expect(r?.ok).toBe(false); + }); + + test("recording is off until doctor asks for it", async () => { + requestLog.enabled = false; + requestLog.entries = []; + stubFetch(200); + await loggedFetch("probe", "https://api.example.com/x"); + expect(requestLog.entries).toEqual([]); + }); + + test("the report file carries the endpoints too", async () => { + stubFetch(200); + await loggedFetch("probe", "https://api.example.com/x"); + const report = buildDoctorReport([], "2026-07-29T00:00:00.000Z"); + expect(report.requests.map((r) => r.url)).toEqual([ + "https://api.example.com/x", + ]); + }); +}); From c41d160455b0af12a10bcf0580a2b1a9ebf042d0 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Wed, 29 Jul 2026 09:39:29 +0700 Subject: [PATCH 13/16] feat: show what each check ran, under that check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version collected the commands and endpoints into two sections at the end of the run, which left the reader correlating a flat list back to the step that produced it. Put the evidence on the row instead: ✓ List available models 2 models available (MiniMax/MiniMax-M2.7, zai-org/GLM-4.7-cc). ↳ GET https://netmind.viettel.vn/gateway/v1/models → 200 (142ms) ✓ Send a test request to the LLM MiniMax/MiniMax-M2.7 answered a test prompt. ↳ POST https://netmind.viettel.vn/gateway/v1/chat/completions → 200 (1.2s) `runChecks` slices whatever the recorders gained while each check ran onto that check's `activity`. Exact, because checks run strictly in sequence — and it still holds for a check that fans out internally: npm-registry issues five `npm config get` concurrently and all five land on its row. Sign-in is marked by hand, since owns it rather than runChecks, so its SSO requests are not the only work in the run with no row to sit under. Activity lines carry no status icon of their own — the row's icon is the verdict, and the separate-section version scored an expected 401 red directly under a check that correctly called it a pass. They render after the fix (status, what, what to do, then evidence); above it they pushed the one actionable line down the screen. The report file keeps the flat `commands`/`requests` arrays as the complete machine-readable record, and now also carries the per-check attribution. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 +- src/DoctorApp.tsx | 141 ++++++++++++------------------ src/components/CheckList.tsx | 12 +++ src/lib/doctor.ts | 62 +++++++++++++ tests/lib/doctor-commands.test.ts | 48 ++++++++++ 5 files changed, 178 insertions(+), 87 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2902c11..c03a9be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,7 +198,7 @@ Pre-flight for everything `install` depends on, so users on a locked-down networ **The report file.** Every run writes `~/.codev-hub/doctor-report.json` (`paths.ts#doctorReportPath`) and **replaces** the previous one — it is a snapshot of "how is this machine right now", and a stale one is worse than none when it is attached to a ticket. It carries the timestamp, CoDev/Node/platform versions, the full proxy environment, per-check outcomes including diagnoses, the summary counts and the next steps. Writing is best-effort in the same sense as `lib/log.ts`: a diagnostic that breaks the command it is diagnosing is worse than no diagnostic, so a failure returns null and the run continues. The serialized JSON goes through `redactSecrets` before hitting disk — this is the file most likely to be emailed around, so it is the last place a token should survive. Note this is *distinct* from the NDJSON diagnostic log, which also receives every check as a `doctor.check` document; the report is one self-contained artifact, the log is the append-only trail. -**Everything doctor does is recorded and shown — both halves.** Subprocesses go through an opt-in recorder in `execAsync` (`npm.ts#commandLog`); HTTP requests go through its sibling in `loggedFetch` (`log.ts#requestLog`). `doctor` switches both on for its run and renders **Commands run** and **Endpoints contacted**, with the same two lists in the report file under `commands` and `requests`. The npm list alone was only half the answer: the connection tests to the backend, Supabase and the gateway never touch `execAsync`, and on a corporate network the endpoints are the *more* useful half — they are what IT allow-lists. Recorded URLs drop the query string (OAuth codes, signed-URL signatures). The endpoints section scores on **reachability, not 2xx**: a 401 means the endpoint answered, and scoring it on `ok` painted expected 401s red while the check rows above correctly called them a pass. Each recorder lives *inside* its seam rather than wrapping it, because callers reach those functions through module-local bindings no external wrapper can intercept — and both stay off by default so `update` and the upload daemon don't accumulate unbounded buffers. `execAsync` carries an opt-in recorder (`npm.ts#commandLog`), which `doctor` switches on for its run. The terminal prints a **Commands run** section — each command with its duration and outcome — and the report file carries the same list under `commands`. The recorder lives *inside* `execAsync` rather than wrapping it from doctor.ts because helpers in npm.ts (`npmGlobalRoot`, `verifyInstall`, …) call it through their module-local binding, which no external wrapper can intercept. It stays off by default so `update` and the upload daemon, which share the same seam, don't accumulate an unbounded buffer. +**Everything doctor does is shown under the check that did it.** Subprocesses go through an opt-in recorder in `execAsync` (`npm.ts#commandLog`); HTTP requests go through its sibling in `loggedFetch` (`log.ts#requestLog`). `doctor` switches both on, and `runChecks` slices whatever the recorders gained while each check ran onto that check's `activity` — exact because checks run strictly in sequence, and it still works for a check that fans out internally (npm-registry runs five `npm config get` concurrently; all five land on its row). Sign-in is the exception, since `` owns it rather than `runChecks`, so `DoctorApp` marks it by hand with `activityMark`/`collectActivity`. Activity lines carry **no status icon**: the row's icon is the verdict, and an earlier revision that listed requests separately with their own icons scored an expected 401 red directly under a check that correctly called it a pass. They render **after** the fix — status, what, what to do, then evidence — because putting them above pushed the one actionable line down the screen. Recorded URLs drop the query string (OAuth codes, signed-URL signatures). Each recorder lives *inside* its seam rather than wrapping it, because callers reach those functions through module-local bindings no external wrapper can intercept, and both stay off by default so `update` and the upload daemon don't accumulate unbounded buffers. Subprocesses go through an opt-in recorder in `execAsync` (`npm.ts#commandLog`); HTTP requests go through its sibling in `loggedFetch` (`log.ts#requestLog`). `doctor` switches both on for its run and renders **Commands run** and **Endpoints contacted**, with the same two lists in the report file under `commands` and `requests`. The npm list alone was only half the answer: the connection tests to the backend, Supabase and the gateway never touch `execAsync`, and on a corporate network the endpoints are the *more* useful half — they are what IT allow-lists. Recorded URLs drop the query string (OAuth codes, signed-URL signatures). The endpoints section scores on **reachability, not 2xx**: a 401 means the endpoint answered, and scoring it on `ok` painted expected 401s red while the check rows above correctly called them a pass. Each recorder lives *inside* its seam rather than wrapping it, because callers reach those functions through module-local bindings no external wrapper can intercept — and both stay off by default so `update` and the upload daemon don't accumulate unbounded buffers. `execAsync` carries an opt-in recorder (`npm.ts#commandLog`), which `doctor` switches on for its run. The terminal prints a **Commands run** section — each command with its duration and outcome — and the report file carries the same list under `commands`. The recorder lives *inside* `execAsync` rather than wrapping it from doctor.ts because helpers in npm.ts (`npmGlobalRoot`, `verifyInstall`, …) call it through their module-local binding, which no external wrapper can intercept. It stays off by default so `update` and the upload daemon, which share the same seam, don't accumulate an unbounded buffer. **Every child process is inventoried.** `tests/lib/doctor-commands.test.ts` asserts the complete, ordered list of commands each group spawns — 10 for a full run, all `npm`, none mutating — so a new subprocess shows up in review rather than quietly appearing on users' machines. It spies at the **`execFile` boundary**, not on `npm.ts#execAsync`: helpers inside npm.ts call `execAsync` through their module-local binding, which a spy on the export cannot intercept. An earlier draft used that spy and reported the `state` group as spawning nothing while it was in fact shelling out four times. That same measurement is what caught `installedAgentsCheck` running `npm root -g` once per agent; it now resolves the root once. diff --git a/src/DoctorApp.tsx b/src/DoctorApp.tsx index 1bbe443..979361c 100644 --- a/src/DoctorApp.tsx +++ b/src/DoctorApp.tsx @@ -11,12 +11,14 @@ import { type AuthData, logout } from "@/lib/auth.js"; import { SSO_URL } from "@/lib/const.js"; import { ACCOUNT_CHECKS, + activityMark, alreadyRetriedWithProxy, buildDoctorReport, buildNextSteps, type Check, type CheckGroup, type CheckOutcome, + collectActivity, type DoctorContext, diagnoseError, doctorOutcome, @@ -134,12 +136,29 @@ export function DoctorApp({ force = false }: DoctorAppProps) { }); }, [phase]); - // Start recording before any check runs, so the "Commands run" section can - // show the user exactly what was executed on their machine. + // Start recording before any check runs, so each row can show exactly what + // it executed on the user's machine. useEffect(() => { startCommandRecording(); }, []); + // Sign-in is the one step not run through runChecks — owns it — so + // its requests need marking by hand or they would be the only work in the + // run with no row to sit under. + const loginMarkRef = useRef<{ commands: number; requests: number } | null>( + null, + ); + useEffect(() => { + if (phase === "login" && !loginMarkRef.current) { + loginMarkRef.current = activityMark(); + } + }, [phase]); + + const loginActivity = useCallback(() => { + const mark = loginMarkRef.current; + return mark ? collectActivity(mark.commands, mark.requests) : undefined; + }, []); + // --force wipes the cached session first, so `sso-login` measures a real // round trip rather than reporting the cache. Same approach as LoginApp. useEffect(() => { @@ -205,33 +224,41 @@ export function DoctorApp({ force = false }: DoctorAppProps) { [exit], ); - const handleLoginDone = useCallback((auth: AuthData) => { - ctxRef.current.accessToken = auth.access_token; - setLoginOutcome({ - key: "sso-login", - label: "Sign in to SSO", - group: "account", - status: "pass", - detail: `Signed in as ${auth.user.email}.`, - }); - setPhase("account"); - }, []); + const handleLoginDone = useCallback( + (auth: AuthData) => { + ctxRef.current.accessToken = auth.access_token; + setLoginOutcome({ + key: "sso-login", + label: "Sign in to SSO", + group: "account", + status: "pass", + detail: `Signed in as ${auth.user.email}.`, + activity: loginActivity(), + }); + setPhase("account"); + }, + [loginActivity], + ); - const handleLoginError = useCallback((err: unknown) => { - const diagnosis = diagnoseError(err, { url: SSO_URL, method: "GET" }); - setLoginOutcome({ - key: "sso-login", - label: "Sign in to SSO", - group: "account", - status: "fail", - detail: diagnosis.what, - fix: diagnosis.fix, - diagnosis, - }); - // Keep going. The account/LLM checks report themselves as skipped without - // a token, which tells the reader exactly how far the flow got. - setPhase("account"); - }, []); + const handleLoginError = useCallback( + (err: unknown) => { + const diagnosis = diagnoseError(err, { url: SSO_URL, method: "GET" }); + setLoginOutcome({ + key: "sso-login", + label: "Sign in to SSO", + group: "account", + status: "fail", + detail: diagnosis.what, + fix: diagnosis.fix, + diagnosis, + activity: loginActivity(), + }); + // Keep going. The account/LLM checks report themselves as skipped without + // a token, which tells the reader exactly how far the flow got. + setPhase("account"); + }, + [loginActivity], + ); // Terminal phase: write the report, compute the exit code, then hold the // frame briefly so Ink flushes the summary before unmounting. @@ -358,64 +385,6 @@ export function DoctorApp({ force = false }: DoctorAppProps) { ), )} - {/* `doctor` runs things on someone else's machine, often a - locked-down one. What it ran should be answerable from the - output, not from the source. Both halves matter: the - subprocesses, and — on a corporate network, more usefully — - the endpoints, which are what IT needs to allow-list. */} - {phase === "done" && commands.length > 0 && ( - Commands run}> - - {/* One Text per row for the same reason as the endpoints - below: a wrapped row must not shift the status column. */} - {commands.map((c, i) => ( - - {`${c.ok ? "✓" : "✗"} ${c.command} (${c.durationMs}ms)`} - - ))} - - { - "All read-only — doctor never installs, uninstalls or changes configuration." - } - - - - )} - {phase === "done" && requests.length > 0 && ( - Endpoints contacted}> - - {requests.map((r, i) => { - // This section answers "was the endpoint reachable?", so ANY - // response counts — a 401 means we got there and were told - // no, which is a success at this layer. Scoring on `ok` - // (2xx) marked the expected 401s red and contradicted the - // check rows above, which correctly call them a pass. Only - // "no response at all" is a failure here. - const reached = r.status !== null; - // One Text, not three: a long URL wrapping across separate - // flex children knocks the status icon out of its column. - return ( - - {`${reached ? "✓" : "✗"} ${r.method} ${r.url} (${ - r.status ?? "no response" - }, ${r.durationMs}ms)`} - - ); - })} - - { - "Reachability only — a 401 here means the endpoint answered. Query strings are omitted; they can carry tokens." - } - - - - )} {phase === "done" && ( Result}> )} + {/* What this check actually ran, under the check that ran it — + "what is this doing on my machine?" answered in place rather + than in a separate list the reader has to correlate. + Last, deliberately: status, then what, then what to do, then the + evidence. Putting it above the fix pushed the one actionable line + down the screen. No status icon either — the row's own icon is the + verdict, and marking an expected 401 red here would contradict it. */} + {outcome.activity?.map((a, i) => ( + + {`↳ ${a.detail} (${a.durationMs}ms)`} + + ))} ); } diff --git a/src/lib/doctor.ts b/src/lib/doctor.ts index f4065ff..df7f5ba 100644 --- a/src/lib/doctor.ts +++ b/src/lib/doctor.ts @@ -130,10 +130,27 @@ export interface Check { run: (ctx: DoctorContext) => Promise; } +/** + * One thing a check actually did — a subprocess it spawned or a request it + * made — shown under that check's row. + * + * Deliberately carries no pass/fail marker of its own. An earlier revision + * listed requests separately with their own icons, and scoring a 401 as a + * failure contradicted the check above it that (correctly) called the same 401 + * a pass. The check's icon is the verdict; these lines are evidence. + */ +export interface CheckActivity { + kind: "command" | "request"; + detail: string; + durationMs: number; +} + export interface CheckOutcome extends CheckResult { key: string; label: string; group: CheckGroup; + /** What this check ran, in order. */ + activity?: CheckActivity[]; } // --------------------------------------------------------------------------- @@ -1507,6 +1524,14 @@ export async function runChecks( ): Promise { const outcomes: CheckOutcome[] = []; for (const check of checks) { + // Checks run strictly in sequence, so anything the recorders gain while + // one is running belongs to it. That makes attribution exact without + // threading a context through every call site — and it still works for a + // check that fans out internally (npm-registry runs five `npm config get` + // concurrently; all five land on its row). + const commandsBefore = commandLog.entries.length; + const requestsBefore = requestLog.entries.length; + let result: CheckResult; try { result = await check.run(ctx); @@ -1524,6 +1549,7 @@ export async function runChecks( key: check.key, label: check.label, group: check.group, + activity: collectActivity(commandsBefore, requestsBefore), }; logCheck(outcome); outcomes.push(outcome); @@ -1704,6 +1730,42 @@ export function recordedRequests(): RequestRecord[] { return [...requestLog.entries]; } +/** + * Everything the recorders gained since the given offsets, as display lines. + * + * Returns undefined rather than an empty array when a check ran nothing, so + * the renderer has one falsy thing to test and the report file stays free of + * empty `activity: []` noise on the many checks that are pure logic. + */ +export function collectActivity( + commandsBefore: number, + requestsBefore: number, +): CheckActivity[] | undefined { + const activity: CheckActivity[] = [ + ...commandLog.entries.slice(commandsBefore).map((c) => ({ + kind: "command" as const, + detail: c.command, + durationMs: c.durationMs, + })), + ...requestLog.entries.slice(requestsBefore).map((r) => ({ + kind: "request" as const, + // The status belongs on the line: it is what distinguishes "the + // endpoint answered no" from "nothing answered at all". + detail: `${r.method} ${r.url} → ${r.status ?? "no response"}`, + durationMs: r.durationMs, + })), + ]; + return activity.length > 0 ? activity : undefined; +} + +/** Snapshot of the recorders, for callers attributing work done outside runChecks. */ +export function activityMark(): { commands: number; requests: number } { + return { + commands: commandLog.entries.length, + requests: requestLog.entries.length, + }; +} + export function buildDoctorReport( outcomes: CheckOutcome[], generatedAt: string, diff --git a/tests/lib/doctor-commands.test.ts b/tests/lib/doctor-commands.test.ts index c343e0d..b8597b2 100644 --- a/tests/lib/doctor-commands.test.ts +++ b/tests/lib/doctor-commands.test.ts @@ -373,3 +373,51 @@ describe("the endpoints doctor contacted", () => { ]); }); }); + +/** + * Attribution: each check shows what it ran, under its own row, rather than in + * a separate list the reader has to correlate back to a step. + */ +describe("each check reports what it ran", () => { + test("a check that shells out lists its commands", async () => { + startCommandRecording(); + const check = ENVIRONMENT_CHECKS.find((c) => c.key === "npm-available"); + const [o] = await runChecks([check as Check], {}); + expect(o?.activity?.map((a) => a.detail)).toEqual(["npm -v"]); + expect(o?.activity?.[0]?.kind).toBe("command"); + }); + + // npm-registry fans out five `npm config get` concurrently; all five belong + // to it, which is what the before/after slice around a sequential run buys. + test("a check that fans out keeps all of its work on one row", async () => { + startCommandRecording(); + const check = ENVIRONMENT_CHECKS.find((c) => c.key === "npm-registry"); + const [o] = await runChecks([check as Check], {}); + expect(o?.activity).toHaveLength(5); + for (const a of o?.activity ?? []) { + expect(a.detail).toMatch(/^npm config get /); + } + }); + + test("work is never attributed to the wrong check", async () => { + startCommandRecording(); + const outcomes = await runChecks( + [ + ENVIRONMENT_CHECKS.find((c) => c.key === "node-version") as Check, + ENVIRONMENT_CHECKS.find((c) => c.key === "npm-available") as Check, + ], + {}, + ); + // node-version is pure logic and must claim nothing. + expect(outcomes[0]?.activity).toBeUndefined(); + expect(outcomes[1]?.activity?.map((a) => a.detail)).toEqual(["npm -v"]); + }); + + test("a pure-logic check carries no activity at all", async () => { + startCommandRecording(); + const outcomes = await runChecks(PREFLIGHT_CHECKS, {}); + // undefined rather than [], so the renderer has one falsy thing to test + // and the report file stays free of empty arrays. + for (const o of outcomes) expect(o.activity).toBeUndefined(); + }); +}); From 215c00886964dc0446be20e9193ee2e952c10e05 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Wed, 29 Jul 2026 09:56:11 +0700 Subject: [PATCH 14/16] feat: report every proxy/TLS variable that is set, and which proxy each request used MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The environment row listed five hard-coded variables, so anything outside that set was invisible — including NODE_EXTRA_CA_CERTS, which is the remedy our own TLS guidance hands out, plus NODE_OPTIONS and npm's npm_config_* overrides. `readProxyEnv` normalizes to fixed fields because that is what the logic needs, but on a machine where the network misbehaves the variable nobody thought to look at is usually the one causing it. It now reports whatever is actually set, verbatim and in the user's own spelling: ▲ Proxy & TLS environment HTTP_PROXY=http://user:***@10.60.129.1:3128 · HTTPS_PROXY=http://user:***@10.60.129.1:3128 · NO_PROXY=localhost · NODE_USE_ENV_PROXY=1 · NODE_EXTRA_CA_CERTS=/etc/ssl/corp-root.pem Each request's activity line now also names the proxy it went through: ↳ POST https://netmind.viettel.vn/codev-backend/config → 401 via http://user:***@10.60.129.1:3128 which is the difference between "this endpoint is unreachable" and "your proxy could not reach this endpoint" — and the only place a NO_PROXY exemption becomes visible, as one request quietly going direct while the rest are proxied. It accounts for scheme and for NODE_USE_ENV_PROXY, so a proxy that is configured but ignored is never claimed as used. Credentials are masked at every display boundary — the check row, the activity lines, the diagnosis context and the report file all end up pasted into tickets, and proxy URLs routinely carry user:pass. `readProxyEnv` keeps the real value; the retry child has to authenticate. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 + src/lib/doctor.ts | 75 +++++++++++++++++++--------- src/lib/proxy.ts | 80 ++++++++++++++++++++++++++++++ tests/lib/proxy.test.ts | 106 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c03a9be..83da4ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,6 +198,8 @@ Pre-flight for everything `install` depends on, so users on a locked-down networ **The report file.** Every run writes `~/.codev-hub/doctor-report.json` (`paths.ts#doctorReportPath`) and **replaces** the previous one — it is a snapshot of "how is this machine right now", and a stale one is worse than none when it is attached to a ticket. It carries the timestamp, CoDev/Node/platform versions, the full proxy environment, per-check outcomes including diagnoses, the summary counts and the next steps. Writing is best-effort in the same sense as `lib/log.ts`: a diagnostic that breaks the command it is diagnosing is worse than no diagnostic, so a failure returns null and the run continues. The serialized JSON goes through `redactSecrets` before hitting disk — this is the file most likely to be emailed around, so it is the last place a token should survive. Note this is *distinct* from the NDJSON diagnostic log, which also receives every check as a `doctor.check` document; the report is one self-contained artifact, the log is the append-only trail. +**The environment is reported as it actually is.** `proxy-env` lists every proxy/TLS variable that is *set*, verbatim and in the user's own spelling, via `proxy.ts#setProxyEnvVars` — including ones `readProxyEnv` does not model (`NODE_EXTRA_CA_CERTS`, `NODE_OPTIONS`, npm's `npm_config_*`). `readProxyEnv` normalizes to fixed fields, which is what the logic needs but hides everything else, and on a misbehaving machine the variable nobody thought to look at is usually the culprit. Each request's activity line also names the proxy it went through (`proxyForUrl`), which accounts for scheme and NO_PROXY — the only place a NO_PROXY exemption becomes visible as one request quietly going direct while the rest are proxied. **Credentials are masked at every display boundary** (`maskProxyCredentials`): proxy URLs routinely carry `user:pass`, and the check row, the activity lines and the report file all end up pasted into tickets. `readProxyEnv` keeps the real value — the retry child has to authenticate. + **Everything doctor does is shown under the check that did it.** Subprocesses go through an opt-in recorder in `execAsync` (`npm.ts#commandLog`); HTTP requests go through its sibling in `loggedFetch` (`log.ts#requestLog`). `doctor` switches both on, and `runChecks` slices whatever the recorders gained while each check ran onto that check's `activity` — exact because checks run strictly in sequence, and it still works for a check that fans out internally (npm-registry runs five `npm config get` concurrently; all five land on its row). Sign-in is the exception, since `` owns it rather than `runChecks`, so `DoctorApp` marks it by hand with `activityMark`/`collectActivity`. Activity lines carry **no status icon**: the row's icon is the verdict, and an earlier revision that listed requests separately with their own icons scored an expected 401 red directly under a check that correctly called it a pass. They render **after** the fix — status, what, what to do, then evidence — because putting them above pushed the one actionable line down the screen. Recorded URLs drop the query string (OAuth codes, signed-URL signatures). Each recorder lives *inside* its seam rather than wrapping it, because callers reach those functions through module-local bindings no external wrapper can intercept, and both stay off by default so `update` and the upload daemon don't accumulate unbounded buffers. Subprocesses go through an opt-in recorder in `execAsync` (`npm.ts#commandLog`); HTTP requests go through its sibling in `loggedFetch` (`log.ts#requestLog`). `doctor` switches both on for its run and renders **Commands run** and **Endpoints contacted**, with the same two lists in the report file under `commands` and `requests`. The npm list alone was only half the answer: the connection tests to the backend, Supabase and the gateway never touch `execAsync`, and on a corporate network the endpoints are the *more* useful half — they are what IT allow-lists. Recorded URLs drop the query string (OAuth codes, signed-URL signatures). The endpoints section scores on **reachability, not 2xx**: a 401 means the endpoint answered, and scoring it on `ok` painted expected 401s red while the check rows above correctly called them a pass. Each recorder lives *inside* its seam rather than wrapping it, because callers reach those functions through module-local bindings no external wrapper can intercept — and both stay off by default so `update` and the upload daemon don't accumulate unbounded buffers. `execAsync` carries an opt-in recorder (`npm.ts#commandLog`), which `doctor` switches on for its run. The terminal prints a **Commands run** section — each command with its duration and outcome — and the report file carries the same list under `commands`. The recorder lives *inside* `execAsync` rather than wrapping it from doctor.ts because helpers in npm.ts (`npmGlobalRoot`, `verifyInstall`, …) call it through their module-local binding, which no external wrapper can intercept. It stays off by default so `update` and the upload daemon, which share the same seam, don't accumulate an unbounded buffer. **Every child process is inventoried.** `tests/lib/doctor-commands.test.ts` asserts the complete, ordered list of commands each group spawns — 10 for a full run, all `npm`, none mutating — so a new subprocess shows up in review rather than quietly appearing on users' machines. It spies at the **`execFile` boundary**, not on `npm.ts#execAsync`: helpers inside npm.ts call `execAsync` through their module-local binding, which a spy on the export cannot intercept. An earlier draft used that spy and reported the `state` group as spawning nothing while it was in fact shelling out four times. That same measurement is what caught `installedAgentsCheck` running `npm root -g` once per agent; it now resolves the root once. diff --git a/src/lib/doctor.ts b/src/lib/doctor.ts index df7f5ba..530d955 100644 --- a/src/lib/doctor.ts +++ b/src/lib/doctor.ts @@ -56,10 +56,13 @@ import { doctorReportPath } from "@/lib/paths.js"; import { backendHost, hasProxyConfigured, + maskProxyCredentials, matchingNoProxyEntry, type ProxyEnv, proxyAutoEnabled, + proxyForUrl, readProxyEnv, + setProxyEnvVars, stripNoProxyFor, } from "@/lib/proxy.js"; import { spawner } from "@/lib/reexec.js"; @@ -301,8 +304,12 @@ function renderChain(chain: ErrorLink[]): string[] { function proxyDescription(proxy: ProxyEnv): string { if (!hasProxyConfigured(proxy)) return "proxy: none"; const parts: string[] = []; - if (proxy.httpsProxy) parts.push(`HTTPS_PROXY=${proxy.httpsProxy}`); - if (proxy.httpProxy) parts.push(`HTTP_PROXY=${proxy.httpProxy}`); + if (proxy.httpsProxy) { + parts.push(`HTTPS_PROXY=${maskProxyCredentials(proxy.httpsProxy)}`); + } + if (proxy.httpProxy) { + parts.push(`HTTP_PROXY=${maskProxyCredentials(proxy.httpProxy)}`); + } return `proxy: ${parts.join(" · ")}`; } @@ -1001,17 +1008,15 @@ const proxyEnvCheck: Check = { run: async () => { const env = readProxyEnv(); const host = backendHost(); - const parts = [ - `HTTP_PROXY: ${env.httpProxy ?? "unset"}`, - `HTTPS_PROXY: ${env.httpsProxy ?? "unset"}`, - `NO_PROXY: ${env.noProxy ?? "unset"}`, - `NODE_USE_ENV_PROXY: ${env.useEnvProxy ? "on" : "unset"}`, - `NODE_USE_SYSTEM_CA: ${env.useSystemCa ? "on" : "unset"}`, - ]; - if (env.tlsRejectUnauthorized !== null) { - parts.push(`NODE_TLS_REJECT_UNAUTHORIZED: ${env.tlsRejectUnauthorized}`); - } - const detail = parts.join(" · "); + // Report what is actually set, verbatim and in the user's own spelling — + // including variables we do not model (NODE_EXTRA_CA_CERTS, NODE_OPTIONS, + // npm's npm_config_*). On a machine where the network misbehaves, the + // variable nobody thought to look at is usually the culprit. + const set = setProxyEnvVars(); + const detail = + set.length > 0 + ? set.map((v) => `${v.name}=${v.value}`).join(" · ") + : "No proxy or TLS environment variables are set."; const problems: string[] = []; if (hasProxyConfigured(env) && proxyAutoEnabled()) { @@ -1694,7 +1699,11 @@ export interface DoctorReport { generatedAt: string; codevVersion: string; node: { version: string; platform: string; arch: string }; - proxy: ProxyEnv & { autoEnabledByCodev: boolean }; + proxy: ProxyEnv & { + autoEnabledByCodev: boolean; + /** Every proxy/TLS variable actually set, in the user's own spelling. */ + environment: Array<{ name: string; value: string }>; + }; summary: { ok: boolean; passed: number; @@ -1747,13 +1756,22 @@ export function collectActivity( detail: c.command, durationMs: c.durationMs, })), - ...requestLog.entries.slice(requestsBefore).map((r) => ({ - kind: "request" as const, - // The status belongs on the line: it is what distinguishes "the - // endpoint answered no" from "nothing answered at all". - detail: `${r.method} ${r.url} → ${r.status ?? "no response"}`, - durationMs: r.durationMs, - })), + ...requestLog.entries.slice(requestsBefore).map((r) => { + // Naming the proxy the request actually went through is the difference + // between "this endpoint is unreachable" and "your proxy could not + // reach this endpoint" — and it is the only way to see, per request, + // that a NO_PROXY entry quietly sent one of them direct. + const via = proxyForUrl(r.url); + return { + kind: "request" as const, + // The status belongs on the line: it is what distinguishes "the + // endpoint answered no" from "nothing answered at all". + detail: `${r.method} ${r.url} → ${r.status ?? "no response"}${ + via ? ` via ${via}` : "" + }`, + durationMs: r.durationMs, + }; + }), ]; return activity.length > 0 ? activity : undefined; } @@ -1766,6 +1784,14 @@ export function activityMark(): { commands: number; requests: number } { }; } +function maskProxyEnv(env: ProxyEnv): ProxyEnv { + return { + ...env, + httpProxy: env.httpProxy ? maskProxyCredentials(env.httpProxy) : null, + httpsProxy: env.httpsProxy ? maskProxyCredentials(env.httpsProxy) : null, + }; +} + export function buildDoctorReport( outcomes: CheckOutcome[], generatedAt: string, @@ -1781,7 +1807,12 @@ export function buildDoctorReport( platform: process.platform, arch: process.arch, }, - proxy: { ...readProxyEnv(), autoEnabledByCodev: proxyAutoEnabled() }, + // Credentials masked: the report is written to be attached to tickets. + proxy: { + ...maskProxyEnv(readProxyEnv()), + autoEnabledByCodev: proxyAutoEnabled(), + environment: setProxyEnvVars(), + }, summary: { ok: !hasFailure(outcomes), passed: count("pass"), diff --git a/src/lib/proxy.ts b/src/lib/proxy.ts index 9e4442c..f81051c 100644 --- a/src/lib/proxy.ts +++ b/src/lib/proxy.ts @@ -94,6 +94,86 @@ export function hasProxyConfigured(proxy: ProxyEnv): boolean { return proxy.httpProxy !== null || proxy.httpsProxy !== null; } +/** + * `http://user:hunter2@10.0.0.1:8080` → `http://user:***@10.0.0.1:8080`. + * + * Proxy URLs routinely carry credentials, and every place we display one — the + * check row, the per-request activity lines, the report file — is somewhere a + * user pastes into a ticket or a chat. Masking happens at the display boundary + * only: `readProxyEnv` keeps the real value, because the child process of the + * proxy retry needs to actually authenticate. + */ +export function maskProxyCredentials(url: string): string { + return url.replace(/(:\/\/[^:/@\s]+):[^@\s]*@/, "$1:***@"); +} + +/** + * Every proxy/TLS-relevant environment variable that is actually set, in the + * user's own spelling. + * + * `readProxyEnv` deliberately normalizes to a fixed set of fields, which is + * what the logic needs — but it hides everything else, including + * `NODE_EXTRA_CA_CERTS` (the remedy our own TLS guidance hands out) and npm's + * `npm_config_*` overrides. On a machine where the network misbehaves, the + * variable nobody thought to look at is usually the one causing it, so report + * whatever is there rather than only what we modelled. + */ +const REPORTED_ENV_VARS = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + "NO_PROXY", + "no_proxy", + "NODE_USE_ENV_PROXY", + "NODE_USE_SYSTEM_CA", + "NODE_EXTRA_CA_CERTS", + "NODE_TLS_REJECT_UNAUTHORIZED", + "NODE_OPTIONS", + "npm_config_proxy", + "npm_config_https_proxy", + "npm_config_registry", + "npm_config_strict_ssl", + "npm_config_cafile", +] as const; + +export function setProxyEnvVars( + env: NodeJS.ProcessEnv = process.env, +): Array<{ name: string; value: string }> { + const out: Array<{ name: string; value: string }> = []; + for (const name of REPORTED_ENV_VARS) { + const value = nonEmpty(env[name]); + if (value !== null) out.push({ name, value: maskProxyCredentials(value) }); + } + return out; +} + +/** + * The proxy that applies to a given URL, or null when none does — accounting + * for the scheme, for NO_PROXY, and for whether Node was actually told to use + * a proxy at all. + */ +export function proxyForUrl( + url: string, + proxy: ProxyEnv = readProxyEnv(), +): string | null { + if (!proxy.useEnvProxy) return null; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + if (matchingNoProxyEntry(proxy, parsed.hostname)) return null; + const chosen = + parsed.protocol === "https:" + ? (proxy.httpsProxy ?? proxy.httpProxy) + : (proxy.httpProxy ?? proxy.httpsProxy); + return chosen ? maskProxyCredentials(chosen) : null; +} + /** The host CoDev's backend, SSO wrapper and skill hub all live on. */ export function backendHost(): string { try { diff --git a/tests/lib/proxy.test.ts b/tests/lib/proxy.test.ts index e165c76..7be7b79 100644 --- a/tests/lib/proxy.test.ts +++ b/tests/lib/proxy.test.ts @@ -5,12 +5,15 @@ import { backendHost, hasProxyConfigured, httpApi, + maskProxyCredentials, matchingNoProxyEntry, noProxyEntryMatches, PROXY_APPLIED_ENV, proxyAutoEnabled, + proxyForUrl, readProxyEnv, resetProxyState, + setProxyEnvVars, stripNoProxyFor, } from "@/lib/proxy.js"; import { spawner } from "@/lib/reexec.js"; @@ -220,3 +223,106 @@ describe("applyEnvProxy", () => { expect(result.noProxyWarning).toContain("*.viettel.vn"); }); }); + +// Proxy URLs routinely carry credentials, and every place one is displayed — +// the check row, the per-request activity lines, the report file — is somewhere +// a user pastes into a ticket or a chat. +describe("credential masking", () => { + test.each([ + ["http://user:hunter2@10.0.0.1:8080", "http://user:***@10.0.0.1:8080"], + ["https://svc:p%40ss@proxy.corp:3128", "https://svc:***@proxy.corp:3128"], + // Nothing to mask — left byte-identical. + ["http://10.0.0.1:8080", "http://10.0.0.1:8080"], + ["http://user@10.0.0.1:8080", "http://user@10.0.0.1:8080"], + ])("%s → %s", (input, expected) => { + expect(maskProxyCredentials(input)).toBe(expected); + }); + + test("readProxyEnv keeps the real value — the retry child must authenticate", () => { + vi.stubEnv("HTTPS_PROXY", "http://user:hunter2@10.0.0.1:8080"); + expect(readProxyEnv().httpsProxy).toBe("http://user:hunter2@10.0.0.1:8080"); + }); +}); + +describe("setProxyEnvVars", () => { + test("reports only what is set, in the user's own spelling", () => { + vi.stubEnv("http_proxy", "http://10.0.0.1:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + const names = setProxyEnvVars().map((v) => v.name); + expect(names).toContain("http_proxy"); + expect(names).toContain("NODE_USE_ENV_PROXY"); + expect(names).not.toContain("HTTPS_PROXY"); + }); + + // readProxyEnv models a fixed set of fields; anything outside it was + // invisible, including the remedy our own TLS guidance hands out. + test("includes variables readProxyEnv does not model", () => { + vi.stubEnv("NODE_EXTRA_CA_CERTS", "/etc/ssl/corp.pem"); + vi.stubEnv("npm_config_registry", "http://mirror.internal/npm"); + vi.stubEnv("NODE_OPTIONS", "--max-old-space-size=4096"); + const names = setProxyEnvVars().map((v) => v.name); + expect(names).toEqual( + expect.arrayContaining([ + "NODE_EXTRA_CA_CERTS", + "npm_config_registry", + "NODE_OPTIONS", + ]), + ); + }); + + test("masks credentials in the reported values", () => { + vi.stubEnv("HTTPS_PROXY", "http://user:hunter2@10.0.0.1:8080"); + const value = setProxyEnvVars().find( + (v) => v.name === "HTTPS_PROXY", + )?.value; + expect(value).toBe("http://user:***@10.0.0.1:8080"); + }); + + test("an empty variable counts as unset", () => { + vi.stubEnv("HTTP_PROXY", " "); + expect(setProxyEnvVars().map((v) => v.name)).not.toContain("HTTP_PROXY"); + }); +}); + +describe("proxyForUrl", () => { + test("returns null when Node was never told to use the proxy", () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + // NODE_USE_ENV_PROXY unset — the proxy is configured but ignored, so no + // request actually went through it and claiming otherwise would mislead. + expect(proxyForUrl("https://api.example.com/x")).toBeNull(); + }); + + test("picks the proxy matching the URL's scheme", () => { + vi.stubEnv("HTTP_PROXY", "http://plain:80"); + vi.stubEnv("HTTPS_PROXY", "http://secure:443"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + expect(proxyForUrl("https://api.example.com/x")).toBe("http://secure:443"); + expect(proxyForUrl("http://api.example.com/x")).toBe("http://plain:80"); + }); + + // The per-request view is the only place a NO_PROXY exemption becomes + // visible: one request quietly going direct while the rest are proxied. + test("returns null for a host exempted by NO_PROXY", () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + vi.stubEnv("NO_PROXY", "*.viettel.vn"); + expect(proxyForUrl("https://netmind.viettel.vn/x")).toBeNull(); + expect(proxyForUrl("https://other.example.com/x")).toBe( + "http://10.0.0.1:8080", + ); + }); + + test("masks credentials", () => { + vi.stubEnv("HTTPS_PROXY", "http://user:hunter2@10.0.0.1:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + expect(proxyForUrl("https://api.example.com/x")).toBe( + "http://user:***@10.0.0.1:8080", + ); + }); + + test("an unparseable URL is not attributed to a proxy", () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + expect(proxyForUrl("not a url")).toBeNull(); + }); +}); From 4f0940105f2656906f3c927f53e66117413a3969 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Wed, 29 Jul 2026 10:05:54 +0700 Subject: [PATCH 15/16] feat: always show the core proxy/TLS variables, set or not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reporting only what was set meant a reader scanning for HTTP_PROXY had to infer its absence from a list that showed everything except it — and most of the failures this command exists for are a *missing* variable, so "unset" is an answer rather than noise: ✓ Proxy & TLS environment HTTP_PROXY=unset · HTTPS_PROXY=unset · NO_PROXY=unset · NODE_USE_ENV_PROXY=unset · NODE_USE_SYSTEM_CA=unset · NODE_EXTRA_CA_CERTS=unset · NODE_TLS_REJECT_UNAUTHORIZED=unset Seven core variables are now always listed. The lowercase spellings, ALL_PROXY, NODE_OPTIONS and npm's npm_config_* stay in a when-set tier: eleven more `unset` entries would bury the seven that matter, and `http_proxy=unset` beside `HTTP_PROXY=unset` reads as a duplicate rather than as information. A set lowercase spelling is reported under its own name while the uppercase stays listed as unset, rather than the uppercase silently absorbing the value — which is what the reader needs to see when only one of the two is exported. The report file carries the same view, with null for unset. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 +- src/lib/doctor.ts | 30 +++++++++++--------- src/lib/proxy.ts | 61 +++++++++++++++++++++++++++-------------- tests/lib/proxy.test.ts | 48 ++++++++++++++++++++++++-------- 4 files changed, 94 insertions(+), 47 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 83da4ed..2435d28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,7 +198,7 @@ Pre-flight for everything `install` depends on, so users on a locked-down networ **The report file.** Every run writes `~/.codev-hub/doctor-report.json` (`paths.ts#doctorReportPath`) and **replaces** the previous one — it is a snapshot of "how is this machine right now", and a stale one is worse than none when it is attached to a ticket. It carries the timestamp, CoDev/Node/platform versions, the full proxy environment, per-check outcomes including diagnoses, the summary counts and the next steps. Writing is best-effort in the same sense as `lib/log.ts`: a diagnostic that breaks the command it is diagnosing is worse than no diagnostic, so a failure returns null and the run continues. The serialized JSON goes through `redactSecrets` before hitting disk — this is the file most likely to be emailed around, so it is the last place a token should survive. Note this is *distinct* from the NDJSON diagnostic log, which also receives every check as a `doctor.check` document; the report is one self-contained artifact, the log is the append-only trail. -**The environment is reported as it actually is.** `proxy-env` lists every proxy/TLS variable that is *set*, verbatim and in the user's own spelling, via `proxy.ts#setProxyEnvVars` — including ones `readProxyEnv` does not model (`NODE_EXTRA_CA_CERTS`, `NODE_OPTIONS`, npm's `npm_config_*`). `readProxyEnv` normalizes to fixed fields, which is what the logic needs but hides everything else, and on a misbehaving machine the variable nobody thought to look at is usually the culprit. Each request's activity line also names the proxy it went through (`proxyForUrl`), which accounts for scheme and NO_PROXY — the only place a NO_PROXY exemption becomes visible as one request quietly going direct while the rest are proxied. **Credentials are masked at every display boundary** (`maskProxyCredentials`): proxy URLs routinely carry `user:pass`, and the check row, the activity lines and the report file all end up pasted into tickets. `readProxyEnv` keeps the real value — the retry child has to authenticate. +**The environment is reported as it actually is.** `proxy-env` renders `proxy.ts#proxyEnvSummary`: the seven core variables **always, set or not** — `unset` is an answer, since most of the failures this command exists for are a *missing* variable and a reader scanning for `HTTP_PROXY` should find it stated rather than infer its absence — plus anything else the user has set, verbatim and in their own spelling, including variables `readProxyEnv` does not model (`NODE_EXTRA_CA_CERTS`, `NODE_OPTIONS`, npm's `npm_config_*`). The lowercase spellings and `npm_config_*` are in the when-set tier only: listing eleven more `unset` entries would bury the seven that matter, and `http_proxy: unset` beside `HTTP_PROXY: unset` reads as a duplicate. `readProxyEnv` normalizes to fixed fields, which is what the logic needs but hides everything else, and on a misbehaving machine the variable nobody thought to look at is usually the culprit. Each request's activity line also names the proxy it went through (`proxyForUrl`), which accounts for scheme and NO_PROXY — the only place a NO_PROXY exemption becomes visible as one request quietly going direct while the rest are proxied. **Credentials are masked at every display boundary** (`maskProxyCredentials`): proxy URLs routinely carry `user:pass`, and the check row, the activity lines and the report file all end up pasted into tickets. `readProxyEnv` keeps the real value — the retry child has to authenticate. **Everything doctor does is shown under the check that did it.** Subprocesses go through an opt-in recorder in `execAsync` (`npm.ts#commandLog`); HTTP requests go through its sibling in `loggedFetch` (`log.ts#requestLog`). `doctor` switches both on, and `runChecks` slices whatever the recorders gained while each check ran onto that check's `activity` — exact because checks run strictly in sequence, and it still works for a check that fans out internally (npm-registry runs five `npm config get` concurrently; all five land on its row). Sign-in is the exception, since `` owns it rather than `runChecks`, so `DoctorApp` marks it by hand with `activityMark`/`collectActivity`. Activity lines carry **no status icon**: the row's icon is the verdict, and an earlier revision that listed requests separately with their own icons scored an expected 401 red directly under a check that correctly called it a pass. They render **after** the fix — status, what, what to do, then evidence — because putting them above pushed the one actionable line down the screen. Recorded URLs drop the query string (OAuth codes, signed-URL signatures). Each recorder lives *inside* its seam rather than wrapping it, because callers reach those functions through module-local bindings no external wrapper can intercept, and both stay off by default so `update` and the upload daemon don't accumulate unbounded buffers. Subprocesses go through an opt-in recorder in `execAsync` (`npm.ts#commandLog`); HTTP requests go through its sibling in `loggedFetch` (`log.ts#requestLog`). `doctor` switches both on for its run and renders **Commands run** and **Endpoints contacted**, with the same two lists in the report file under `commands` and `requests`. The npm list alone was only half the answer: the connection tests to the backend, Supabase and the gateway never touch `execAsync`, and on a corporate network the endpoints are the *more* useful half — they are what IT allow-lists. Recorded URLs drop the query string (OAuth codes, signed-URL signatures). The endpoints section scores on **reachability, not 2xx**: a 401 means the endpoint answered, and scoring it on `ok` painted expected 401s red while the check rows above correctly called them a pass. Each recorder lives *inside* its seam rather than wrapping it, because callers reach those functions through module-local bindings no external wrapper can intercept — and both stay off by default so `update` and the upload daemon don't accumulate unbounded buffers. `execAsync` carries an opt-in recorder (`npm.ts#commandLog`), which `doctor` switches on for its run. The terminal prints a **Commands run** section — each command with its duration and outcome — and the report file carries the same list under `commands`. The recorder lives *inside* `execAsync` rather than wrapping it from doctor.ts because helpers in npm.ts (`npmGlobalRoot`, `verifyInstall`, …) call it through their module-local binding, which no external wrapper can intercept. It stays off by default so `update` and the upload daemon, which share the same seam, don't accumulate an unbounded buffer. diff --git a/src/lib/doctor.ts b/src/lib/doctor.ts index 530d955..99d0696 100644 --- a/src/lib/doctor.ts +++ b/src/lib/doctor.ts @@ -59,10 +59,11 @@ import { maskProxyCredentials, matchingNoProxyEntry, type ProxyEnv, + type ProxyEnvVar, proxyAutoEnabled, + proxyEnvSummary, proxyForUrl, readProxyEnv, - setProxyEnvVars, stripNoProxyFor, } from "@/lib/proxy.js"; import { spawner } from "@/lib/reexec.js"; @@ -1008,15 +1009,15 @@ const proxyEnvCheck: Check = { run: async () => { const env = readProxyEnv(); const host = backendHost(); - // Report what is actually set, verbatim and in the user's own spelling — - // including variables we do not model (NODE_EXTRA_CA_CERTS, NODE_OPTIONS, - // npm's npm_config_*). On a machine where the network misbehaves, the - // variable nobody thought to look at is usually the culprit. - const set = setProxyEnvVars(); - const detail = - set.length > 0 - ? set.map((v) => `${v.name}=${v.value}`).join(" · ") - : "No proxy or TLS environment variables are set."; + // The core variables are always listed, set or not: most of the failures + // this command exists for are a *missing* variable, so "unset" is an + // answer rather than an omission. Anything else the user has set is + // appended — including variables we do not model (NODE_OPTIONS, npm's + // npm_config_*), because on a misbehaving machine the one nobody thought + // to look at is usually the culprit. + const detail = proxyEnvSummary() + .map((v) => `${v.name}=${v.value ?? "unset"}`) + .join(" · "); const problems: string[] = []; if (hasProxyConfigured(env) && proxyAutoEnabled()) { @@ -1701,8 +1702,11 @@ export interface DoctorReport { node: { version: string; platform: string; arch: string }; proxy: ProxyEnv & { autoEnabledByCodev: boolean; - /** Every proxy/TLS variable actually set, in the user's own spelling. */ - environment: Array<{ name: string; value: string }>; + /** + * The proxy/TLS environment as reported to the user: the core variables + * always, with `null` for unset, plus anything else that is set. + */ + environment: ProxyEnvVar[]; }; summary: { ok: boolean; @@ -1811,7 +1815,7 @@ export function buildDoctorReport( proxy: { ...maskProxyEnv(readProxyEnv()), autoEnabledByCodev: proxyAutoEnabled(), - environment: setProxyEnvVars(), + environment: proxyEnvSummary(), }, summary: { ok: !hasFailure(outcomes), diff --git a/src/lib/proxy.ts b/src/lib/proxy.ts index f81051c..58dd63b 100644 --- a/src/lib/proxy.ts +++ b/src/lib/proxy.ts @@ -107,30 +107,29 @@ export function maskProxyCredentials(url: string): string { return url.replace(/(:\/\/[^:/@\s]+):[^@\s]*@/, "$1:***@"); } -/** - * Every proxy/TLS-relevant environment variable that is actually set, in the - * user's own spelling. - * - * `readProxyEnv` deliberately normalizes to a fixed set of fields, which is - * what the logic needs — but it hides everything else, including - * `NODE_EXTRA_CA_CERTS` (the remedy our own TLS guidance hands out) and npm's - * `npm_config_*` overrides. On a machine where the network misbehaves, the - * variable nobody thought to look at is usually the one causing it, so report - * whatever is there rather than only what we modelled. - */ -const REPORTED_ENV_VARS = [ +// Always reported, set or not. "unset" is an answer in its own right — most of +// the failures this command exists for are a *missing* variable, and a reader +// scanning for HTTP_PROXY should find it stated rather than have to infer its +// absence from a list that only shows what happens to be present. +const ALWAYS_REPORTED_ENV_VARS = [ "HTTP_PROXY", - "http_proxy", "HTTPS_PROXY", - "https_proxy", - "ALL_PROXY", - "all_proxy", "NO_PROXY", - "no_proxy", "NODE_USE_ENV_PROXY", "NODE_USE_SYSTEM_CA", "NODE_EXTRA_CA_CERTS", "NODE_TLS_REJECT_UNAUTHORIZED", +] as const; + +// Reported only when set. Listing these as "unset" too would put eleven more +// entries on the row for the overwhelming majority of users, and the lowercase +// spellings would read as duplicates of the uppercase ones above. +const WHEN_SET_ENV_VARS = [ + "http_proxy", + "https_proxy", + "ALL_PROXY", + "all_proxy", + "no_proxy", "NODE_OPTIONS", "npm_config_proxy", "npm_config_https_proxy", @@ -139,11 +138,31 @@ const REPORTED_ENV_VARS = [ "npm_config_cafile", ] as const; -export function setProxyEnvVars( +export interface ProxyEnvVar { + name: string; + /** null when the variable is unset (or set to whitespace). */ + value: string | null; +} + +/** + * The proxy/TLS environment as shown to the user. + * + * `readProxyEnv` normalizes to a fixed set of fields, which is what the logic + * needs — but it hides everything else, including `NODE_EXTRA_CA_CERTS` (the + * remedy our own TLS guidance hands out) and npm's `npm_config_*` overrides. + * This reports the core variables always (with null for unset) plus anything + * else the user has set, in their own spelling. + */ +export function proxyEnvSummary( env: NodeJS.ProcessEnv = process.env, -): Array<{ name: string; value: string }> { - const out: Array<{ name: string; value: string }> = []; - for (const name of REPORTED_ENV_VARS) { +): ProxyEnvVar[] { + const mask = (v: string | null) => + v === null ? null : maskProxyCredentials(v); + const out: ProxyEnvVar[] = ALWAYS_REPORTED_ENV_VARS.map((name) => ({ + name, + value: mask(nonEmpty(env[name])), + })); + for (const name of WHEN_SET_ENV_VARS) { const value = nonEmpty(env[name]); if (value !== null) out.push({ name, value: maskProxyCredentials(value) }); } diff --git a/tests/lib/proxy.test.ts b/tests/lib/proxy.test.ts index 7be7b79..082f6aa 100644 --- a/tests/lib/proxy.test.ts +++ b/tests/lib/proxy.test.ts @@ -10,10 +10,10 @@ import { noProxyEntryMatches, PROXY_APPLIED_ENV, proxyAutoEnabled, + proxyEnvSummary, proxyForUrl, readProxyEnv, resetProxyState, - setProxyEnvVars, stripNoProxyFor, } from "@/lib/proxy.js"; import { spawner } from "@/lib/reexec.js"; @@ -244,14 +244,36 @@ describe("credential masking", () => { }); }); -describe("setProxyEnvVars", () => { - test("reports only what is set, in the user's own spelling", () => { +describe("proxyEnvSummary", () => { + // "unset" is an answer: most failures this command exists for are a + // *missing* variable, and a reader scanning for HTTP_PROXY should find it + // stated rather than infer its absence. + test("always lists the core variables, set or not", () => { + // Explicit env, not process.env: the package manager exports + // npm_config_registry for its own scripts, which would leak into an + // assertion about the exact list. + const summary = proxyEnvSummary({}); + expect(summary.map((v) => v.name)).toEqual([ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "NODE_USE_ENV_PROXY", + "NODE_USE_SYSTEM_CA", + "NODE_EXTRA_CA_CERTS", + "NODE_TLS_REJECT_UNAUTHORIZED", + ]); + for (const v of summary) expect(v.value).toBeNull(); + }); + + test("reports a set variable in the user's own spelling", () => { vi.stubEnv("http_proxy", "http://10.0.0.1:8080"); - vi.stubEnv("NODE_USE_ENV_PROXY", "1"); - const names = setProxyEnvVars().map((v) => v.name); - expect(names).toContain("http_proxy"); - expect(names).toContain("NODE_USE_ENV_PROXY"); - expect(names).not.toContain("HTTPS_PROXY"); + const summary = proxyEnvSummary(); + // The lowercase spelling is appended; the uppercase stays listed as unset + // rather than silently absorbing the lowercase value. + expect(summary.find((v) => v.name === "http_proxy")?.value).toBe( + "http://10.0.0.1:8080", + ); + expect(summary.find((v) => v.name === "HTTP_PROXY")?.value).toBeNull(); }); // readProxyEnv models a fixed set of fields; anything outside it was @@ -260,7 +282,7 @@ describe("setProxyEnvVars", () => { vi.stubEnv("NODE_EXTRA_CA_CERTS", "/etc/ssl/corp.pem"); vi.stubEnv("npm_config_registry", "http://mirror.internal/npm"); vi.stubEnv("NODE_OPTIONS", "--max-old-space-size=4096"); - const names = setProxyEnvVars().map((v) => v.name); + const names = proxyEnvSummary().map((v) => v.name); expect(names).toEqual( expect.arrayContaining([ "NODE_EXTRA_CA_CERTS", @@ -272,15 +294,17 @@ describe("setProxyEnvVars", () => { test("masks credentials in the reported values", () => { vi.stubEnv("HTTPS_PROXY", "http://user:hunter2@10.0.0.1:8080"); - const value = setProxyEnvVars().find( + const value = proxyEnvSummary().find( (v) => v.name === "HTTPS_PROXY", )?.value; expect(value).toBe("http://user:***@10.0.0.1:8080"); }); - test("an empty variable counts as unset", () => { + test("an empty variable is reported as unset, not as a blank value", () => { vi.stubEnv("HTTP_PROXY", " "); - expect(setProxyEnvVars().map((v) => v.name)).not.toContain("HTTP_PROXY"); + expect( + proxyEnvSummary().find((v) => v.name === "HTTP_PROXY")?.value, + ).toBeNull(); }); }); From 8c00b8d7378421aa90f1c583f2d247ae3b13f0d4 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Wed, 29 Jul 2026 11:24:05 +0700 Subject: [PATCH 16/16] fix: handle case-insensitive environment variables on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI failed on windows-latest only. Both failures traced to the same fact: Windows environment variables are case-insensitive, so `http_proxy` and `HTTP_PROXY` are one variable. One of them was a real bug, not a test artifact. The proxy retry built its child environment as `{...process.env, HTTP_PROXY: proxy.http}`, which on Windows yields an object holding *both* the user's original key with the stale value and ours with the new one. Which of the two the child sees is left to chance, and if the stale one wins the retry silently tests a proxy address the user never typed. Overrides now go through `overrideEnvVar`, which removes every other spelling first. That is right on POSIX too: there both spellings are real and both are consulted, so leaving the old one behind is the same ambiguity by a different mechanism. NO_PROXY stripping likewise covers every spelling rather than the two that were hardcoded. The other was the test asserting that setting `http_proxy` leaves `HTTP_PROXY` unset — true on POSIX, unknowable on Windows. It and its neighbours now pass an explicit environment to `proxyEnvSummary` instead of mutating the process, which is hermetic and platform-independent. The retry test also looked up `env.NO_PROXY` directly; it now searches case-insensitively and ignores empty spellings, since an unset variant legitimately stays empty because there is nothing in it to strip. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/doctor.ts | 38 +++++++++++++--------- src/lib/proxy.ts | 31 ++++++++++++++++++ tests/lib/doctor.test.ts | 25 ++++++++++++-- tests/lib/proxy.test.ts | 70 +++++++++++++++++++++++++++++++++------- 4 files changed, 135 insertions(+), 29 deletions(-) diff --git a/src/lib/doctor.ts b/src/lib/doctor.ts index 99d0696..06a67ac 100644 --- a/src/lib/doctor.ts +++ b/src/lib/doctor.ts @@ -55,9 +55,11 @@ import { import { doctorReportPath } from "@/lib/paths.js"; import { backendHost, + envVarKeys, hasProxyConfigured, maskProxyCredentials, matchingNoProxyEntry, + overrideEnvVar, type ProxyEnv, type ProxyEnvVar, proxyAutoEnabled, @@ -1951,25 +1953,31 @@ export function rerunDoctorWithProxy( return 1; } const traceId = currentTraceId(); - const env: NodeJS.ProcessEnv = { - ...process.env, - HTTP_PROXY: proxy.http, - HTTPS_PROXY: proxy.https, - NODE_USE_ENV_PROXY: "1", - // Intercepting proxies re-sign TLS with a corporate root, so reading the - // OS trust store is part of "try it with the proxy", not a separate step. - NODE_USE_SYSTEM_CA: "1", - // Offer the prompt only once — if the retry still fails, the summary must - // be allowed to print rather than asking again. - [DOCTOR_PROXY_ENV]: "1", - ...(traceId ? { CODEV_TRACE_PARENT: traceId } : {}), - }; + const env: NodeJS.ProcessEnv = { ...process.env }; + // Assigned through overrideEnvVar rather than spread: Windows environment + // variables are case-insensitive, so a user's existing `http_proxy` would + // survive alongside our `HTTP_PROXY` and the child could pick up the stale + // address — testing a proxy the user did not type. + overrideEnvVar(env, "HTTP_PROXY", proxy.http); + overrideEnvVar(env, "HTTPS_PROXY", proxy.https); + overrideEnvVar(env, "NODE_USE_ENV_PROXY", "1"); + // Intercepting proxies re-sign TLS with a corporate root, so reading the + // OS trust store is part of "try it with the proxy", not a separate step. + overrideEnvVar(env, "NODE_USE_SYSTEM_CA", "1"); + // Offer the prompt only once — if the retry still fails, the summary must + // be allowed to print rather than asking again. + overrideEnvVar(env, DOCTOR_PROXY_ENV, "1"); + if (traceId) overrideEnvVar(env, "CODEV_TRACE_PARENT", traceId); + // A NO_PROXY entry covering our own backend would send that traffic direct // and defeat the proxy we're testing — the documented cause of "Login // failed". Drop it for the child only; the user's environment is untouched. + // Every spelling is stripped, since any of them would defeat the retry. const host = backendHost(); - if (env.NO_PROXY) env.NO_PROXY = stripNoProxyFor(env.NO_PROXY, host); - if (env.no_proxy) env.no_proxy = stripNoProxyFor(env.no_proxy, host); + for (const key of envVarKeys(env, "NO_PROXY")) { + const value = env[key]; + if (value) env[key] = stripNoProxyFor(value, host); + } // execArgv is forwarded so the child keeps whatever flags this process was // started with — without it, a `pnpm dev` run (node + tsx loader flags) diff --git a/src/lib/proxy.ts b/src/lib/proxy.ts index 58dd63b..6b7f9bf 100644 --- a/src/lib/proxy.ts +++ b/src/lib/proxy.ts @@ -234,6 +234,37 @@ export function matchingNoProxyEntry( return null; } +/** + * Set a variable in a child environment, removing any other spelling of it. + * + * Windows environment variables are **case-insensitive**, so `http_proxy` and + * `HTTP_PROXY` are one variable — but a plain `{...process.env, HTTP_PROXY: x}` + * produces an object holding both the user's original key (with the old value) + * and ours. Handing that to `spawnSync` leaves which one wins to chance, and if + * the stale one wins the proxy retry silently tests the wrong address. + * + * Removing the variants is right on POSIX too: there both spellings are real + * and both are consulted, so leaving the old one behind is the same ambiguity + * by a different mechanism. + */ +export function overrideEnvVar( + env: NodeJS.ProcessEnv, + name: string, + value: string, +): void { + const lower = name.toLowerCase(); + for (const key of Object.keys(env)) { + if (key !== name && key.toLowerCase() === lower) delete env[key]; + } + env[name] = value; +} + +/** Every key in `env` that spells `name`, in any case. */ +export function envVarKeys(env: NodeJS.ProcessEnv, name: string): string[] { + const lower = name.toLowerCase(); + return Object.keys(env).filter((key) => key.toLowerCase() === lower); +} + /** NO_PROXY with every entry that would exempt `host` removed. */ export function stripNoProxyFor(noProxy: string, host: string): string { return noProxy diff --git a/tests/lib/doctor.test.ts b/tests/lib/doctor.test.ts index d34a306..1b36909 100644 --- a/tests/lib/doctor.test.ts +++ b/tests/lib/doctor.test.ts @@ -37,7 +37,7 @@ import { import * as log from "@/lib/log.js"; import * as npm from "@/lib/npm.js"; import * as proxy from "@/lib/proxy.js"; -import { PROXY_APPLIED_ENV } from "@/lib/proxy.js"; +import { envVarKeys, PROXY_APPLIED_ENV } from "@/lib/proxy.js"; import * as reexec from "@/lib/reexec.js"; import * as tls from "@/lib/tls.js"; @@ -1185,11 +1185,32 @@ describe("the proxy retry command", () => { rerunDoctorWithProxy(PROXY, []); const env = spawn.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv; - expect(env.NO_PROXY).toBe("localhost,127.0.0.1"); + // Looked up case-insensitively and ignoring empty spellings: Windows + // stores the key in whatever case it was first created with, so + // `env.NO_PROXY` alone is not portable, and an unset variant legitimately + // stays empty because there is nothing in it to strip. + const effective = envVarKeys(env, "NO_PROXY") + .map((key) => env[key]) + .filter((value): value is string => Boolean(value)); + expect(effective).toEqual(["localhost,127.0.0.1"]); // The user's own environment is untouched — only the child's copy changes. expect(process.env.NO_PROXY).toBe("localhost,*.viettel.vn,127.0.0.1"); }); + // Windows environment variables are case-insensitive, so a user's existing + // `http_proxy` would otherwise survive the spread alongside our `HTTP_PROXY` + // and the child could start up on the stale address. + test("leaves exactly one spelling of each variable it overrides", () => { + vi.stubEnv("http_proxy", "http://stale:1"); + const spawn = captureSpawn(); + rerunDoctorWithProxy(PROXY, []); + + const env = spawn.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv; + const keys = envVarKeys(env, "HTTP_PROXY"); + expect(keys).toEqual(["HTTP_PROXY"]); + expect(env.HTTP_PROXY).toBe("http://10.0.0.1:8080"); + }); + test("propagates the child's exit code", () => { vi.spyOn(reexec.spawner, "spawnSync") // biome-ignore lint/suspicious/noExplicitAny: minimal SpawnSyncReturns stub diff --git a/tests/lib/proxy.test.ts b/tests/lib/proxy.test.ts index 082f6aa..7deeb35 100644 --- a/tests/lib/proxy.test.ts +++ b/tests/lib/proxy.test.ts @@ -3,11 +3,13 @@ import { BACKEND_URL } from "@/lib/const.js"; import { applyEnvProxy, backendHost, + envVarKeys, hasProxyConfigured, httpApi, maskProxyCredentials, matchingNoProxyEntry, noProxyEntryMatches, + overrideEnvVar, PROXY_APPLIED_ENV, proxyAutoEnabled, proxyEnvSummary, @@ -265,9 +267,11 @@ describe("proxyEnvSummary", () => { for (const v of summary) expect(v.value).toBeNull(); }); + // Explicit env, not process.env: on Windows environment variables are + // case-insensitive, so stubbing `http_proxy` there also answers to + // `HTTP_PROXY` and the two spellings cannot be told apart. test("reports a set variable in the user's own spelling", () => { - vi.stubEnv("http_proxy", "http://10.0.0.1:8080"); - const summary = proxyEnvSummary(); + const summary = proxyEnvSummary({ http_proxy: "http://10.0.0.1:8080" }); // The lowercase spelling is appended; the uppercase stays listed as unset // rather than silently absorbing the lowercase value. expect(summary.find((v) => v.name === "http_proxy")?.value).toBe( @@ -279,10 +283,11 @@ describe("proxyEnvSummary", () => { // readProxyEnv models a fixed set of fields; anything outside it was // invisible, including the remedy our own TLS guidance hands out. test("includes variables readProxyEnv does not model", () => { - vi.stubEnv("NODE_EXTRA_CA_CERTS", "/etc/ssl/corp.pem"); - vi.stubEnv("npm_config_registry", "http://mirror.internal/npm"); - vi.stubEnv("NODE_OPTIONS", "--max-old-space-size=4096"); - const names = proxyEnvSummary().map((v) => v.name); + const names = proxyEnvSummary({ + NODE_EXTRA_CA_CERTS: "/etc/ssl/corp.pem", + npm_config_registry: "http://mirror.internal/npm", + NODE_OPTIONS: "--max-old-space-size=4096", + }).map((v) => v.name); expect(names).toEqual( expect.arrayContaining([ "NODE_EXTRA_CA_CERTS", @@ -293,17 +298,17 @@ describe("proxyEnvSummary", () => { }); test("masks credentials in the reported values", () => { - vi.stubEnv("HTTPS_PROXY", "http://user:hunter2@10.0.0.1:8080"); - const value = proxyEnvSummary().find( - (v) => v.name === "HTTPS_PROXY", - )?.value; + const value = proxyEnvSummary({ + HTTPS_PROXY: "http://user:hunter2@10.0.0.1:8080", + }).find((v) => v.name === "HTTPS_PROXY")?.value; expect(value).toBe("http://user:***@10.0.0.1:8080"); }); test("an empty variable is reported as unset, not as a blank value", () => { - vi.stubEnv("HTTP_PROXY", " "); expect( - proxyEnvSummary().find((v) => v.name === "HTTP_PROXY")?.value, + proxyEnvSummary({ HTTP_PROXY: " " }).find( + (v) => v.name === "HTTP_PROXY", + )?.value, ).toBeNull(); }); }); @@ -350,3 +355,44 @@ describe("proxyForUrl", () => { expect(proxyForUrl("not a url")).toBeNull(); }); }); + +/** + * Windows environment variables are case-insensitive, so `http_proxy` and + * `HTTP_PROXY` are one variable — but a plain `{...process.env, HTTP_PROXY: x}` + * yields an object holding both the user's original key (old value) and ours. + * Handing that to spawnSync leaves which one wins to chance, and a stale win + * means the proxy retry silently tests an address the user never typed. + */ +describe("child environment overrides", () => { + test("replaces the value and removes every other spelling", () => { + const env: NodeJS.ProcessEnv = { + http_proxy: "http://stale:1", + HTTP_Proxy: "http://also-stale:2", + PATH: "/usr/bin", + }; + overrideEnvVar(env, "HTTP_PROXY", "http://fresh:8080"); + expect(envVarKeys(env, "HTTP_PROXY")).toEqual(["HTTP_PROXY"]); + expect(env.HTTP_PROXY).toBe("http://fresh:8080"); + // Unrelated variables survive untouched. + expect(env.PATH).toBe("/usr/bin"); + }); + + test("setting a variable that is not present just adds it", () => { + const env: NodeJS.ProcessEnv = {}; + overrideEnvVar(env, "NODE_USE_ENV_PROXY", "1"); + expect(env).toEqual({ NODE_USE_ENV_PROXY: "1" }); + }); + + test("envVarKeys finds every spelling and nothing else", () => { + const env: NodeJS.ProcessEnv = { + NO_PROXY: "a", + no_proxy: "b", + NOT_A_PROXY: "c", + }; + expect(envVarKeys(env, "NO_PROXY").sort()).toEqual([ + "NO_PROXY", + "no_proxy", + ]); + expect(envVarKeys(env, "MISSING")).toEqual([]); + }); +});