From 91d1349f3c5055365a9306e17758cadbcbb59d8e Mon Sep 17 00:00:00 2001 From: hekataion Date: Thu, 23 Jul 2026 17:37:39 -0400 Subject: [PATCH 1/2] feat: add volute doctor command for self-diagnosis and bug-report bundles (#829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit volute doctor runs local health checks (daemon, setup, provider, DB, node, install type, isolation, disk, minds) and prints pass/fail results. volute doctor --bundle writes a sanitized tarball for attaching to bug reports: redacted config.json (secrets.json never read), env.json with all values masked, scrubbed daemon and mind log tails, status output. Aggressive redaction — secret keys, OAuth tokens, Bearer headers, and credential-shaped strings are all masked. The redaction helpers (redactValue, redactConfigJson, redactEnvJson, redactLogText) are pure and tested by test/doctor-redaction.test.ts (15 tests). Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/doctor.ts | 398 ++++++++++++++++++++++++++++ packages/daemon/src/lib/doctor.ts | 165 ++++++++++++ src/cli.ts | 5 + test/doctor-redaction.test.ts | 218 +++++++++++++++ 4 files changed, 786 insertions(+) create mode 100644 packages/cli/src/commands/doctor.ts create mode 100644 packages/daemon/src/lib/doctor.ts create mode 100644 test/doctor-redaction.test.ts diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts new file mode 100644 index 00000000..33699ca2 --- /dev/null +++ b/packages/cli/src/commands/doctor.ts @@ -0,0 +1,398 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { statfs } from "node:fs/promises"; +import { arch, homedir, platform, release, tmpdir, type } from "node:os"; +import { resolve } from "node:path"; +import { sharedEnvPath } from "@volute/daemon/lib/config/env.js"; +import { + daemonLogSource, + getDaemonUrl, + getServiceMode, + modeLabel, + readDaemonConfig, +} from "@volute/daemon/lib/config/service-mode.js"; +import { computeSetupStatus, readGlobalConfig } from "@volute/daemon/lib/config/setup.js"; +import { + type BasicMind, + readMigrationInfo, + readMindsBasic, + redactConfigJson, + redactEnvJson, + redactLogText, +} from "@volute/daemon/lib/doctor.js"; +import { stateDir, voluteHome, voluteSystemDir } from "@volute/daemon/lib/mind/registry.js"; +import { exec } from "@volute/daemon/lib/util/exec.js"; +import { readLogTail } from "@volute/daemon/lib/util/log-tail.js"; +import { command } from "../lib/command.js"; +import { getAuthToken } from "../lib/daemon-client.js"; + +type CheckState = "pass" | "fail" | "warn"; +type Check = { label: string; state: CheckState; detail?: string }; + +const ICON: Record = { pass: "✓", fail: "✗", warn: "⚠" }; + +function dbPath(): string { + return process.env.VOLUTE_DB_PATH || resolve(voluteSystemDir(), "volute.db"); +} + +function humanBytes(n: number): string { + const units = ["B", "KB", "MB", "GB", "TB"]; + let v = n; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)}${units[i]}`; +} + +/** Two-digit-padded local timestamp for the bundle filename: YYYY-MM-DD-HHMMSS. */ +function bundleStamp(d = new Date()): string { + const p = (n: number) => String(n).padStart(2, "0"); + return ( + `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}-` + + `${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}` + ); +} + +/** + * The daemon's local API origin. Built from daemon.json (falling back to defaults) + * rather than the CLI's resolveDaemonUrl(), which process.exit()s when daemon.json is + * missing — fatal for a diagnostic that must run on a machine that was never set up. + */ +function daemonBaseUrl(): string { + const { port, internalPort } = readDaemonConfig(); + return getDaemonUrl("127.0.0.1", internalPort ?? port); +} + +/** Best-effort JSON GET against the daemon; null on any failure (daemon down, timeout, auth). */ +async function fetchDaemonJson(path: string): Promise { + const base = daemonBaseUrl(); + const headers: Record = { Origin: base }; + const token = getAuthToken(); + if (token) headers.Authorization = `Bearer ${token}`; + try { + const res = await fetch(`${base}${path}`, { headers, signal: AbortSignal.timeout(3000) }); + if (!res.ok) return null; + return (await res.json()) as T; + } catch { + return null; + } +} + +type DaemonMind = { + name: string; + running: boolean; + status?: string; + parent?: string; + lastError?: unknown; +}; + +type Diagnostics = { + checks: Check[]; + /** Minds as the daemon reports them (authoritative), or null when unreachable. */ + daemonMinds: DaemonMind[] | null; +}; + +async function runDiagnostics(): Promise { + const checks: Check[] = []; + const config = readGlobalConfig(); + + // --- Daemon reachable --- + const health = await fetchDaemonJson<{ ok?: boolean; version?: string }>("/api/health"); + const daemonUp = !!health?.ok; + if (health?.ok) { + checks.push({ + label: "Daemon reachable", + state: "pass", + detail: `${daemonBaseUrl()}${health.version ? ` (v${health.version})` : ""}`, + }); + } else { + checks.push({ + label: "Daemon reachable", + state: "fail", + detail: `no response at ${daemonBaseUrl()} — start it with \`volute up\``, + }); + } + + // --- Setup complete + provider configured --- + const setupState = computeSetupStatus(config); + const providers = Object.keys(config.ai?.providers ?? {}); + if (setupState === "complete" && providers.length > 0) { + checks.push({ label: "Setup complete", state: "pass" }); + } else if (setupState === "complete") { + checks.push({ + label: "Setup complete", + state: "warn", + detail: "setup finished but no AI provider is configured", + }); + } else { + checks.push({ + label: "Setup complete", + state: "fail", + detail: + setupState === "in-progress" ? "setup started but not finished" : "run `volute setup`", + }); + } + + // Provider presence — names only, never values/keys. + checks.push( + providers.length > 0 + ? { label: "Provider config present", state: "pass", detail: providers.sort().join(", ") } + : { label: "Provider config present", state: "fail", detail: "no providers configured" }, + ); + + // --- Database opens + migration version --- + const path = dbPath(); + if (!existsSync(path)) { + checks.push({ label: "Database", state: "warn", detail: "no volute.db yet (fresh install?)" }); + } else { + const info = await readMigrationInfo(path); + if (info.ok) { + checks.push({ + label: "Database", + state: "pass", + detail: `opens OK, ${info.applied} migration${info.applied === 1 ? "" : "s"} applied`, + }); + } else { + checks.push({ label: "Database", state: "fail", detail: info.error }); + } + } + + // --- Node / npm versions --- + checks.push({ label: "Node version", state: "pass", detail: process.version }); + const npmVersion = await exec("npm", ["--version"]) + .then((s) => s.trim()) + .catch(() => null); + checks.push( + npmVersion + ? { label: "npm version", state: "pass", detail: npmVersion } + : { label: "npm version", state: "warn", detail: "npm not found on PATH" }, + ); + + // --- Install type --- + checks.push({ label: "Install type", state: "pass", detail: modeLabel(getServiceMode()) }); + + // --- Isolation mode --- + const isolation = config.setup?.isolation; + checks.push( + isolation + ? { label: "Isolation mode", state: "pass", detail: isolation } + : { label: "Isolation mode", state: "warn", detail: "not configured (none)" }, + ); + + // --- Disk space in ~/.volute --- + try { + // ~/.volute may not exist yet on a fresh machine; statfs the nearest existing + // ancestor so the free-space figure is still meaningful. + const home = voluteHome(); + const st = await statfs(existsSync(home) ? home : homedir()); + const free = Number(st.bavail) * Number(st.bsize); + const total = Number(st.blocks) * Number(st.bsize); + const pctFree = total > 0 ? (free / total) * 100 : 0; + checks.push({ + label: "Disk space", + state: pctFree < 5 ? "warn" : "pass", + detail: `${humanBytes(free)} free of ${humanBytes(total)} (${pctFree.toFixed(0)}%)`, + }); + } catch (err) { + checks.push({ + label: "Disk space", + state: "warn", + detail: err instanceof Error ? err.message : String(err), + }); + } + + // --- Per-mind process status --- + const daemonMinds = await fetchDaemonJson("/api/minds"); + if (daemonMinds) { + if (daemonMinds.length === 0) { + checks.push({ label: "Minds", state: "pass", detail: "none configured" }); + } else { + for (const m of daemonMinds) { + const status = m.status ?? (m.running ? "running" : "stopped"); + const failed = m.lastError ? " (last turn failed)" : ""; + const state: CheckState = + status === "running" || status === "sleeping" ? "pass" : m.lastError ? "fail" : "warn"; + checks.push({ label: `Mind: ${m.name}`, state, detail: `${status}${failed}` }); + } + } + } else { + // Couldn't get live status — fall back to the registry's last-known running + // flag. Distinguish a down daemon from one that's up but rejected our request + // (e.g. the CLI isn't logged in), since the fix differs. + const why = daemonUp ? "not authenticated — try `volute login`" : "daemon unreachable"; + const rows: BasicMind[] = existsSync(path) ? await readMindsBasic(path) : []; + if (rows.length === 0) { + checks.push({ label: "Minds", state: "warn", detail: `${why}; none in registry` }); + } else { + for (const m of rows) { + checks.push({ + label: `Mind: ${m.name}`, + state: "warn", + detail: `${m.running ? "running?" : "stopped"} (${why} — registry value)`, + }); + } + } + } + + return { checks, daemonMinds }; +} + +function renderReport(checks: Check[]): string { + const lines: string[] = ["volute doctor", "═════════════", ""]; + const width = Math.max(...checks.map((c) => c.label.length)); + for (const c of checks) { + const detail = c.detail ? ` ${c.detail}` : ""; + lines.push(`${ICON[c.state]} ${c.label.padEnd(width)}${detail}`); + } + const failed = checks.filter((c) => c.state === "fail").length; + const warned = checks.filter((c) => c.state === "warn").length; + lines.push(""); + lines.push( + failed === 0 && warned === 0 + ? "All checks passed." + : `${failed} failed, ${warned} warning${warned === 1 ? "" : "s"}.`, + ); + return `${lines.join("\n")}\n`; +} + +function systemInfo(): string { + return [ + `date: ${new Date().toISOString()}`, + `os: ${type()} ${release()} (${platform()}/${arch()})`, + `node: ${process.version}`, + `install type: ${modeLabel(getServiceMode())}`, + `isolation: ${readGlobalConfig().setup?.isolation ?? "none"}`, + `volute home: ${voluteHome()}`, + ].join("\n"); +} + +/** Capture `volute status` output for the bundle without exiting the current process. */ +async function captureStatus(): Promise { + const self = process.argv[1]; + try { + // Replay this process's own loader flags (`process.execArgv`) so the re-invocation + // works both in the shipped build (`node dist/cli.js`) and in dev, where the entry + // is a .ts file that needs the tsx loader that launched us. + return await exec(process.execPath, [...process.execArgv, self, "status"]); + } catch (err) { + const e = err as { stdout?: string; stderr?: string }; + return ( + e.stdout || e.stderr || `(volute status failed: ${err instanceof Error ? err.message : err})` + ); + } +} + +async function writeBundle(report: string, minds: DaemonMind[] | null): Promise { + const stamp = bundleStamp(); + const stage = mkdtempSync(resolve(tmpdir(), "volute-doctor-")); + const root = resolve(stage, `volute-doctor-${stamp}`); + mkdirSync(root, { recursive: true }); + + const write = (rel: string, content: string) => { + const dest = resolve(root, rel); + mkdirSync(resolve(dest, ".."), { recursive: true }); + writeFileSync(dest, content); + }; + + // Diagnostics + system info + `volute status` (scrubbed defensively). + write("report.txt", report); + write("system.txt", `${systemInfo()}\n`); + write("status.txt", redactLogText(await captureStatus())); + + // Redacted config.json (secrets.json is NEVER copied). + const cfgPath = resolve(voluteSystemDir(), "config.json"); + if (existsSync(cfgPath)) { + write("config.json", redactConfigJson(readFileSync(cfgPath, "utf-8"))); + } + + // Redacted shared env.json. + const envPath = sharedEnvPath(); + if (existsSync(envPath)) { + write("env.json", redactEnvJson(readFileSync(envPath, "utf-8"))); + } + + // Daemon log tail (file-backed modes) or a pointer to the journal. Logs go in + // verbatim except for credential-shaped substrings, which are scrubbed. + const src = daemonLogSource(getServiceMode()); + if (src.kind === "file") { + write("logs/daemon.log", redactLogText(`${readLogTail(src.path, 200).join("\n")}\n`)); + } else { + const journal = await exec("sh", ["-c", src.command]).catch( + (err) => `(could not read journal: ${err instanceof Error ? err.message : err})`, + ); + write("logs/daemon.log", redactLogText(journal)); + } + + // Per-mind: redacted env.json + recent log tail. Prefer the daemon/registry + // list, but also sweep the centralized state dir directly — a missing or corrupt + // volute.db is exactly the kind of failure this bundle exists to capture, and the + // per-mind logs live under state// regardless of the DB's health. + const path = dbPath(); + const listed = minds + ? minds.map((m) => m.name) + : existsSync(path) + ? (await readMindsBasic(path)).map((m) => m.name) + : []; + const stateRoot = resolve(voluteSystemDir(), "state"); + const fromState = existsSync(stateRoot) + ? readdirSync(stateRoot, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + : []; + const mindNames = [...new Set([...listed, ...fromState])].sort(); + for (const name of mindNames) { + const mindEnv = resolve(stateDir(name), "env.json"); + if (existsSync(mindEnv)) { + write(`minds/${name}/env.json`, redactEnvJson(readFileSync(mindEnv, "utf-8"))); + } + const mindLog = resolve(stateDir(name), "logs", "mind.log"); + const tail = readLogTail(mindLog, 200); + if (tail.length > 0) write(`minds/${name}/mind.log`, redactLogText(`${tail.join("\n")}\n`)); + } + + const outPath = resolve(process.cwd(), `volute-doctor-${stamp}.tar.gz`); + try { + await exec("tar", ["-czf", outPath, "-C", stage, `volute-doctor-${stamp}`]); + } finally { + rmSync(stage, { recursive: true, force: true }); + } + return outPath; +} + +const cmd = command({ + name: "volute doctor", + description: + "Diagnose the local Volute install; --bundle writes a sanitized report to attach to a bug report", + flags: { + bundle: { + type: "boolean", + description: "Write a redacted diagnostic tarball to the current directory", + }, + }, + examples: ["volute doctor", "volute doctor --bundle"], + async run({ flags }) { + const { checks, daemonMinds } = await runDiagnostics(); + const report = renderReport(checks); + + if (!flags.bundle) { + process.stdout.write(report); + return; + } + + process.stdout.write(report); + const outPath = await writeBundle(report, daemonMinds); + console.log(`\nBundle written: ${outPath}`); + console.log("It redacts secrets, but skim it before sharing."); + }, +}); + +export const run = cmd.execute; diff --git a/packages/daemon/src/lib/doctor.ts b/packages/daemon/src/lib/doctor.ts new file mode 100644 index 00000000..1bfb1b3f --- /dev/null +++ b/packages/daemon/src/lib/doctor.ts @@ -0,0 +1,165 @@ +import { sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/libsql"; + +/** + * Shared logic for `volute doctor` — the one command a user runs to gather + * evidence when something breaks, since Volute keeps no telemetry. + * + * The redaction helpers here are the load-bearing part: the `--bundle` tarball is + * meant to be attached to a bug report, so it MUST NOT leak secrets. Be aggressive + * — a redacted non-secret is harmless, a leaked API key is not. These functions are + * pure and covered by test/doctor-redaction.test.ts. + */ + +/** The placeholder written in place of any redacted value. */ +export const REDACTED = "[REDACTED]"; + +/** + * Object keys whose value is a secret (or contains one). Matched case-insensitively + * as a substring, so `apiKey`, `ANTHROPIC_API_KEY`, `refreshToken`, `client_secret` + * all match. Deliberately broad — over-matching only hides a non-secret field name. + */ +const SECRET_KEY_RE = + /(api[-_]?key|secret|token|password|passphrase|bearer|oauth|credential|private[-_]?key|client[-_]?secret|webhook|cookie|session|auth)/i; + +/** + * String values that look like a credential regardless of the key they sit under + * (a bare token pasted into an innocuously-named field). Prefix/format based so it + * rarely fires on ordinary config strings. + */ +const SECRET_VALUE_RE = + /(sk-[A-Za-z0-9]|sk_[A-Za-z0-9]|vmt_|vlt_|xox[baprs]-|gh[pousr]_|github_pat_|AKIA[0-9A-Z]{12}|-----BEGIN|eyJ[A-Za-z0-9_-]{10}|Bearer\s+\S)/; + +/** A long, high-entropy-looking token/hash with no spaces (base64/hex/opaque id). */ +function looksLikeOpaqueToken(s: string): boolean { + return s.length >= 40 && /^[A-Za-z0-9+/=_-]+$/.test(s); +} + +export function looksLikeSecretString(s: string): boolean { + return SECRET_VALUE_RE.test(s) || looksLikeOpaqueToken(s); +} + +function maskAllValues(obj: Record): Record { + const out: Record = {}; + for (const k of Object.keys(obj)) out[k] = REDACTED; + return out; +} + +/** + * Recursively redact a parsed config value. A key matching SECRET_KEY_RE has its + * entire subtree replaced with the placeholder; an `env`-shaped map (arbitrary + * env-var names → values, e.g. backup.env) has every value masked; string leaves + * that look like credentials are masked. Non-secret keys and values pass through so + * the bundle stays useful for diagnosis. + */ +export function redactValue(value: unknown, key?: string): unknown { + if (key !== undefined && SECRET_KEY_RE.test(key)) return REDACTED; + if (Array.isArray(value)) return value.map((v) => redactValue(v)); + if (value !== null && typeof value === "object") { + // An env-var map can hold secrets under arbitrary, innocuous-looking names, so + // mask every value rather than trusting the key names. + if (key === "env") return maskAllValues(value as Record); + const out: Record = {}; + for (const [k, v] of Object.entries(value)) out[k] = redactValue(v, k); + return out; + } + if (typeof value === "string" && looksLikeSecretString(value)) return REDACTED; + return value; +} + +/** + * Redact the text of a config.json before it goes in the bundle. On a parse failure + * the whole file is dropped rather than passed through — a torn/corrupt config we + * can't reason about could still contain a key. + */ +export function redactConfigJson(text: string): string { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return "[config.json could not be parsed — omitted so no secret can leak]\n"; + } + return `${JSON.stringify(redactValue(parsed), null, 2)}\n`; +} + +/** + * Redact an env.json (shared or per-mind). Every value is masked unconditionally — + * env values are user-supplied and any of them may be a secret — while the keys are + * kept so a supporter can see which variables are set. + */ +export function redactEnvJson(text: string): string { + let parsed: Record; + try { + parsed = JSON.parse(text); + } catch { + return "[env.json could not be parsed — omitted so no secret can leak]\n"; + } + return `${JSON.stringify(maskAllValues(parsed), null, 2)}\n`; +} + +/** + * Scrub credential-shaped substrings out of free-form text (log tails, captured + * command output). Logs are included verbatim for debugging, but a daemon or mind + * can print a token in an error, so we mask three shapes: `secret-name=value` + * pairs, `Bearer `, and standalone known token prefixes. Over-masking a log + * line is harmless; leaking a key in one is not. + */ +const LOG_SCRUBBERS: Array<[RegExp, string]> = [ + // key=value / key: value where the key names a credential — keep the key, mask the value. + [ + /\b(api[_-]?key|secret|token|password|passwd|pwd|bearer|authorization|credential|access[_-]?key(?:[_-]?id)?|secret[_-]?access[_-]?key|refresh[_-]?token)\b(\s*[=:]\s*)"?([^\s"',}]+)/gi, + `$1$2${REDACTED}`, + ], + // Authorization: Bearer + [/\bBearer\s+[A-Za-z0-9._-]+/gi, `Bearer ${REDACTED}`], + // Standalone tokens by known prefix/shape, wherever they appear. + [ + /\b(sk-[A-Za-z0-9-]{6,}|sk_[A-Za-z0-9-]{6,}|vmt_[A-Za-z0-9-]{6,}|vlt_[A-Za-z0-9-]{6,}|xox[baprs]-[A-Za-z0-9-]+|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]+|AKIA[0-9A-Z]{12,}|eyJ[A-Za-z0-9._-]{10,})\b/g, + REDACTED, + ], +]; + +export function redactLogText(text: string): string { + let out = text; + for (const [re, replacement] of LOG_SCRUBBERS) out = out.replace(re, replacement); + return out; +} + +// --- Database inspection (read-only, no migration) --- + +export type MigrationInfo = + | { ok: true; applied: number; latestMillis: number | null } + | { ok: false; error: string }; + +export type BasicMind = { name: string; running: boolean; parent: string | null }; + +/** + * Open the volute.db read-only and report how many migrations have been applied. + * Unlike getDb(), this never runs migrate() — doctor only reports state, it must not + * mutate a database it may be sharing with a live daemon. + */ +export async function readMigrationInfo(dbPath: string): Promise { + try { + const db = drizzle({ connection: { url: `file:${dbPath}` } }); + const rows = (await db.all( + sql.raw("SELECT count(*) AS n, max(created_at) AS latest FROM __drizzle_migrations"), + )) as Array<{ n: number; latest: number | null }>; + const row = rows[0]; + return { ok: true, applied: Number(row?.n ?? 0), latestMillis: row?.latest ?? null }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** Read the minds table (name, running, parent) without migrating. Empty on any failure. */ +export async function readMindsBasic(dbPath: string): Promise { + try { + const db = drizzle({ connection: { url: `file:${dbPath}` } }); + const rows = (await db.all( + sql.raw("SELECT name, running, parent FROM minds ORDER BY name"), + )) as Array<{ name: string; running: number; parent: string | null }>; + return rows.map((r) => ({ name: r.name, running: r.running === 1, parent: r.parent })); + } catch { + return []; + } +} diff --git a/src/cli.ts b/src/cli.ts index 1406d23d..fed26fb1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -36,6 +36,7 @@ const ungatedCommands = new Set([ "login", "logout", "service", + "doctor", // diagnostics must run on a broken/half-set-up machine undefined, ]); // A mind only exists because a set-up daemon spawned it, so the gate has nothing @@ -111,6 +112,9 @@ switch (command) { case "status": await import("./commands/status.js").then((m) => m.run(args)); break; + case "doctor": + await import("@volute/cli/commands/doctor.js").then((m) => m.run(args)); + break; case "extension": await import("@volute/cli/commands/extension.js").then((m) => m.run(args)); break; @@ -160,6 +164,7 @@ System: setup [--system] [--cli] First-time setup up / down / restart Daemon control status Show daemon & service status + doctor [--bundle] Diagnose the install / write a bug-report bundle backup init/create/list/restore Back up and restore the system extension list/install/uninstall Manage extensions login / logout CLI authentication diff --git a/test/doctor-redaction.test.ts b/test/doctor-redaction.test.ts new file mode 100644 index 00000000..76e059c9 --- /dev/null +++ b/test/doctor-redaction.test.ts @@ -0,0 +1,218 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + looksLikeSecretString, + REDACTED, + redactConfigJson, + redactEnvJson, + redactLogText, + redactValue, +} from "../packages/daemon/src/lib/doctor.js"; + +/** + * The `volute doctor --bundle` tarball is attached to bug reports, so its whole + * reason for existing is that it leaks NO secrets. These tests are the critical + * path: a real API key, OAuth token, or env value must never survive redaction. + */ +describe("doctor redaction", () => { + describe("redactValue", () => { + it("redacts values under secret-looking keys, keeps the key names", () => { + const out = redactValue({ + apiKey: "sk-ant-abc123", + access_token: "xoxb-9999", + refreshToken: "rt-secret", + password: "hunter2", + client_secret: "cs-live-xyz", + }) as Record; + assert.equal(out.apiKey, REDACTED); + assert.equal(out.access_token, REDACTED); + assert.equal(out.refreshToken, REDACTED); + assert.equal(out.password, REDACTED); + assert.equal(out.client_secret, REDACTED); + }); + + it("keeps non-secret fields untouched", () => { + const out = redactValue({ + name: "myserver", + port: 1618, + setup: { isolation: "sandbox", type: "system" }, + models: ["claude-opus-4-8"], + }); + assert.deepEqual(out, { + name: "myserver", + port: 1618, + setup: { isolation: "sandbox", type: "system" }, + models: ["claude-opus-4-8"], + }); + }); + + it("preserves provider NAMES but redacts their credentials", () => { + const out = redactValue({ + ai: { + providers: { + anthropic: { apiKey: "sk-ant-topsecret" }, + openai: { oauth: { refresh: "r", access: "a", expires: 1 } }, + }, + }, + }) as { ai: { providers: Record } }; + // Names visible (useful for "provider config present")... + assert.deepEqual(Object.keys(out.ai.providers).sort(), ["anthropic", "openai"]); + // ...but no credential material. + assert.equal((out.ai.providers.anthropic as Record).apiKey, REDACTED); + assert.deepEqual(out.ai.providers.openai, { oauth: REDACTED }); + assert.ok(!JSON.stringify(out).includes("sk-ant-topsecret")); + }); + + it("masks every value of an env-shaped map regardless of key name", () => { + const out = redactValue({ + env: { AWS_ACCESS_KEY_ID: "AKIA...", INNOCENT_LOOKING: "still-a-secret" }, + }) as { env: Record }; + assert.equal(out.env.AWS_ACCESS_KEY_ID, REDACTED); + assert.equal(out.env.INNOCENT_LOOKING, REDACTED); + }); + + it("redacts credential-shaped string values under innocuous keys", () => { + const out = redactValue({ note: "sk-ant-api03-abcdef", greeting: "hello world" }) as Record< + string, + unknown + >; + assert.equal(out.note, REDACTED); + assert.equal(out.greeting, "hello world"); + }); + }); + + describe("looksLikeSecretString", () => { + it("flags known token shapes", () => { + for (const s of [ + "sk-ant-api03-xxxx", + "vmt_abcdef123456", + "xoxb-123-456", + "ghp_abcdefghij", + "Bearer abc.def", + "-----BEGIN PRIVATE KEY-----", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", + "a".repeat(48), + ]) { + assert.ok(looksLikeSecretString(s), `expected secret: ${s}`); + } + }); + + it("does not flag ordinary short strings", () => { + for (const s of ["sandbox", "127.0.0.1", "claude-opus-4-8", "hello world", "1618"]) { + assert.ok(!looksLikeSecretString(s), `unexpected secret: ${s}`); + } + }); + }); + + describe("redactLogText", () => { + it("masks credential values in key=value / key: value pairs, keeps the key", () => { + const out = redactLogText( + "connecting token=abc123secret\nAPI_KEY: sk-livexxxx\npassword=hunter2 done", + ); + assert.ok(/token=\[REDACTED\]/.test(out)); + assert.ok(/API_KEY: \[REDACTED\]/.test(out)); + assert.ok(/password=\[REDACTED\]/.test(out)); + assert.ok(!out.includes("abc123secret")); + assert.ok(!out.includes("hunter2")); + // Surrounding log text is preserved. + assert.ok(out.includes("connecting")); + assert.ok(out.includes("done")); + }); + + it("masks Bearer tokens and standalone token shapes anywhere in a line", () => { + const out = redactLogText( + "GET /x Authorization: Bearer eyJhbGciOiJ.payload.sig\nmint vmt_abcdef123456 for alice\nkey sk-ant-api03-LEAKED here", + ); + assert.ok(!out.includes("eyJhbGciOiJ.payload.sig")); + assert.ok(!out.includes("vmt_abcdef123456")); + assert.ok(!out.includes("sk-ant-api03-LEAKED")); + assert.ok(out.includes("for alice")); + }); + + it("leaves ordinary log lines untouched", () => { + const line = "2026-07-23 10:00:00 mind alice started on port 4100"; + assert.equal(redactLogText(line), line); + }); + }); + + describe("redactEnvJson", () => { + it("masks all values, keeps keys", () => { + const out = redactEnvJson( + JSON.stringify({ ANTHROPIC_API_KEY: "sk-ant-xxx", MY_FLAG: "true", PORT: "9000" }), + ); + const parsed = JSON.parse(out); + assert.deepEqual(parsed, { + ANTHROPIC_API_KEY: REDACTED, + MY_FLAG: REDACTED, + PORT: REDACTED, + }); + assert.ok(!out.includes("sk-ant-xxx")); + assert.ok(!out.includes("true")); + }); + + it("drops the file entirely when it can't be parsed", () => { + const out = redactEnvJson("{ this is not: json"); + assert.ok(out.includes("could not be parsed")); + assert.ok(!out.includes("json}")); + }); + }); + + describe("redactConfigJson — no secret survives", () => { + // A config with a secret planted in every place one could plausibly live. + const SECRETS = [ + "sk-ant-api03-REALKEY", + "oauth-refresh-REALTOKEN", + "oauth-access-REALTOKEN", + "restic-passphrase-REAL", + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "vmt_realminttoken", + ]; + const config = { + name: "server", + port: 1618, + setup: { isolation: "sandbox", type: "system" }, + ai: { + providers: { + anthropic: { apiKey: "sk-ant-api03-REALKEY" }, + openai: { + oauth: { refresh: "oauth-refresh-REALTOKEN", access: "oauth-access-REALTOKEN" }, + }, + }, + models: ["claude-opus-4-8"], + }, + backup: { + repository: "s3:example", + password: "restic-passphrase-REAL", + env: { + AWS_ACCESS_KEY_ID: "AKIAIOSFODNN7EXAMPLE", + AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }, + }, + someToken: "vmt_realminttoken", + }; + + it("contains none of the planted secret values", () => { + const out = redactConfigJson(JSON.stringify(config)); + for (const secret of SECRETS) { + assert.ok(!out.includes(secret), `leaked secret: ${secret}`); + } + }); + + it("keeps non-secret operational fields for diagnosis", () => { + const out = redactConfigJson(JSON.stringify(config)); + const parsed = JSON.parse(out); + assert.equal(parsed.name, "server"); + assert.equal(parsed.port, 1618); + assert.equal(parsed.setup.isolation, "sandbox"); + assert.deepEqual(parsed.ai.models, ["claude-opus-4-8"]); + assert.deepEqual(Object.keys(parsed.ai.providers).sort(), ["anthropic", "openai"]); + assert.equal(parsed.backup.repository, "s3:example"); + }); + + it("drops the file entirely when it can't be parsed", () => { + const out = redactConfigJson("not json at all"); + assert.ok(out.includes("could not be parsed")); + }); + }); +}); From 6e417c60d3e9401077b6ab16f011228af1facd77 Mon Sep 17 00:00:00 2001 From: hekataion Date: Fri, 24 Jul 2026 09:52:16 -0400 Subject: [PATCH 2/2] feat: add Chrome detection check to volute doctor Reports whether will work by detecting Chrome-family browsers using the same logic as the pages extension. Co-Authored-By: Claude --- packages/cli/src/commands/doctor.ts | 71 ++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 33699ca2..b95c9780 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,4 +1,6 @@ import { + accessSync, + constants, existsSync, mkdirSync, mkdtempSync, @@ -9,7 +11,7 @@ import { } from "node:fs"; import { statfs } from "node:fs/promises"; import { arch, homedir, platform, release, tmpdir, type } from "node:os"; -import { resolve } from "node:path"; +import { delimiter, join, resolve } from "node:path"; import { sharedEnvPath } from "@volute/daemon/lib/config/env.js"; import { daemonLogSource, @@ -38,6 +40,61 @@ type Check = { label: string; state: CheckState; detail?: string }; const ICON: Record = { pass: "✓", fail: "✗", warn: "⚠" }; +// --- Chrome/Chromium detection for pages preview --- + +const MAC_CHROME_CANDIDATES = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", +]; + +const PATH_CHROME_CANDIDATES = [ + "google-chrome", + "google-chrome-stable", + "chromium", + "chromium-browser", + "brave-browser", + "microsoft-edge", +]; + +function isExecutable(p: string): boolean { + try { + accessSync(p, constants.X_OK); + return true; + } catch { + return false; + } +} + +/** + * Locate a Chrome-family browser for `volute pages preview`. Returns the path if + * found, null otherwise. Mirrors the logic in @volute/pages preview.ts so doctor + * reports the same result as the actual preview command. + */ +function findChromeBrowser(): string | null { + const override = process.env.VOLUTE_CHROMIUM; + if (override) return override; + + if (platform() === "darwin") { + for (const candidate of MAC_CHROME_CANDIDATES) { + if (isExecutable(candidate)) return candidate; + } + return null; + } + + // Linux/other: walk PATH looking for an executable. + const dirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean); + for (const name of PATH_CHROME_CANDIDATES) { + for (const dir of dirs) { + const full = join(dir, name); + if (isExecutable(full)) return full; + } + } + return null; +} + function dbPath(): string { return process.env.VOLUTE_DB_PATH || resolve(voluteSystemDir(), "volute.db"); } @@ -188,6 +245,18 @@ async function runDiagnostics(): Promise { : { label: "Isolation mode", state: "warn", detail: "not configured (none)" }, ); + // --- Chrome for pages preview --- + const chromePath = findChromeBrowser(); + checks.push( + chromePath + ? { label: "Chrome (pages preview)", state: "pass", detail: chromePath } + : { + label: "Chrome (pages preview)", + state: "warn", + detail: "no Chrome-family browser found — `volute pages preview` won't work", + }, + ); + // --- Disk space in ~/.volute --- try { // ~/.volute may not exist yet on a fresh machine; statfs the nearest existing