diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 11c6e2336..bf5c17043 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -197,8 +197,8 @@ is removed after use. The app never types into an existing terminal or pane. ## 3. Doctor -Source: `hack doctor --json --path ` per registered project, plus one run -outside any project for machine checks (until R7c), on open and every 10 +Source: `hack doctor --json --path ` per registered project, plus +`hack doctor --machine --json` for machine findings, on open and every 10 minutes while visible. The versioned audit gives `{ok:true,data:{schema:1,checks,summary,counts}}`; checks include `id`, `status`, `detail`, `area`, `scope` and `fix` (R7a). | Card part | Content | @@ -380,8 +380,8 @@ Each is small enough for one PR. 7. **R5b** Activity reader: `hack activity --json [--project] [--limit]`. 8. **R6** Action progress: `hack up|down|restart --json --progress` emitting ndjson `{type:"progress", service, phase}` before the final envelope. 9. **R7a — exists.** Doctor audit schema 1 adds per-check `area`, `scope: machine | project` and `fix: {kind: safe | admin | manual, args[], doc_url}` inside the existing `{ok,data}` envelope. Unknown checks default to Other/manual with no executable args. A category is guidance, not permission to run it. -10. **R7b** One fix without prompts: `hack doctor --fix --only --yes --json`, only for `kind: safe`. -11. **R7c** Machine-only doctor: `hack doctor --machine --json` (no project), so the app does not need to de-duplicate. +10. **R7b — exists.** `hack doctor --fix --only --yes --json` executes one allowlisted safe fix and rechecks it. Initially only `hack gitignore` is safe; other IDs and mixed modes refuse before writes. Results use `{ok,data:{schema:1,checks,summary,counts,fix}}`; `fix` records `id`, `changed`, `confirmed`. +11. **R7c — exists.** `hack doctor --machine --json` skips project discovery even inside an invalid project. It reports only machine scopes for the selected backend plus global secrets hygiene, using the schema-1 `{ok,data}` envelope. Read-only engine/service probes may run; nothing is started or repaired. `--path`, project/browser flags and mutation modes refuse. For R7b project fixes, the app supplies its selected project cwd or appends `--path ` to the advertised argv. 12. **R8** Daemon unit staleness: `service.unit_stale` and reason in `hack daemon status --json`, and `version` there. 13. **R9** (optional) Push updates: `GET /v1/projects?wait=` long-poll. 14. **R10** Env key state: `key_state: ok | missing | unreadable` in `hack env list --json` (no values). diff --git a/docs/reference/cli.md b/docs/reference/cli.md index af5b832cf..3a24803fc 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1623,6 +1623,9 @@ hack doctor [options] | --- | --- | | `--path, -p ` | Run a project command against a repo path (overrides cwd search) | | `--fix` | Attempt safe auto-remediations (network + CoreDNS + CA) | +| `--machine` | Observe machine checks only, without discovering a project or repairing state | +| `--only ` | With --fix --yes, run exactly one allowlisted safe repair | +| `--yes` | Consent to the one safe repair selected with --fix --only | | `--migrate-env-config` | Migrate legacy hack.env.json/.env state to the new env config files | | `--accept-tracked-env-changes` | With --migrate-env-config and no prompt, allow deleting or editing git-tracked files (hack.env.json, the Compose file) | | `--domain-migration preview|apply|rollback` | Preview, apply, or roll back only this project's hack.local migration | diff --git a/docs/versions/v5/troubleshooting.md b/docs/versions/v5/troubleshooting.md index a76eac28b..636dbe35b 100644 --- a/docs/versions/v5/troubleshooting.md +++ b/docs/versions/v5/troubleshooting.md @@ -8,6 +8,10 @@ an original key, not an automatic repair. `hack doctor --json` returns `{ok:true,data:{schema:1,checks,summary,counts}}`. Each check adds an area, machine/project scope and a fix category (`safe`, `admin` or `manual`), argv after `hack`, and a documentation URL. Empty argv means consult the docs. The audit remains read-only; `ok:true` means the audit ran, while error findings still exit 1. Unknown checks offer no runnable repair. +For a targeted repair, `hack doctor --fix --only 'hack gitignore' --yes --json --path ` repairs only Hack's managed ignore block, keeping user lines. It needs a Git checkout and leaves adopted, unsafe or read-only inputs alone. It rechecks the result and exits 1 when unconfirmed. `data.fix` reports `id`, `changed` and `confirmed`; no other check is run or repaired. Other IDs, absent consent and combinations with other repair modes refuse before writes. Broad `--fix --json` still refuses. + +Use `hack doctor --machine --json` for a machine-only audit. It skips project discovery and project checks even from a broken project checkout, and inspects only global stores for secrets hygiene. It also omits the retained gateway checks, whose resolver reads registered project configurations. Every finding has machine scope; failed or unavailable observations remain warnings/errors. It runs bounded read-only probes, never starts Docker, hackd or a native runtime, and accepts no project selection, browser observation or repair flags. The selected backend and home still come from the same environment as ordinary Doctor. + The rows marked **next.17 source** describe merged code intended for next.17. They do not change an installed next.16 bundle. See the [release notes](../guides/v5-release-notes.md) for version boundaries. diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 76eb83c91..0307038b1 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -152,6 +152,10 @@ import { NATIVE_SOURCE_CHECK, type NativeSourceReport, } from "../lib/doctor-native-source.ts"; +import { + DOCTOR_SAFE_FIX_ID, + runDoctorSafeFix, +} from "../lib/doctor-safe-fix.ts"; import { inspectSecretIntegrity } from "../lib/doctor-secret-integrity.ts"; import { findCrossCheckoutInstances, @@ -430,6 +434,26 @@ const optBrowserResult = defineOption({ const doctorOptions = [ optPath, optFix, + defineOption({ + name: "machine", + type: "boolean", + long: "--machine", + description: + "Observe machine checks only, without discovering a project or repairing state", + } as const), + defineOption({ + name: "only", + type: "string", + long: "--only", + valueHint: "", + description: "With --fix --yes, run exactly one allowlisted safe repair", + } as const), + defineOption({ + name: "yes", + type: "boolean", + long: "--yes", + description: "Consent to the one safe repair selected with --fix --only", + } as const), optMigrateEnvConfig, optAcceptTrackedEnvChanges, optDomainMigration, @@ -741,15 +765,29 @@ async function runNativePreflightMode( const handleDoctor: CommandHandlerFor = async ({ args, }): Promise => { + if (args.options.machine) { + assertMachineDoctorOptions(args.options); + } + if (selectedDoctorFixRequested(args.options)) { + return await runSelectedDoctorFix(args); + } if (args.options.nativePreflight) { return await runNativePreflightMode(args); } if (args.options.profile !== undefined) { throw new CliUsageError("--profile applies only to --native-preflight."); } - const adopted = await preflightDoctorProject({ - startDir: resolveDoctorStartDir(args.options.path), - }); + return await runDoctorAudit(args); +}; + +async function runDoctorAudit( + args: Parameters>[0]["args"] +): Promise { + const adopted = args.options.machine + ? null + : await preflightDoctorProject({ + startDir: resolveDoctorStartDir(args.options.path), + }); const mappingResult = await maybeRunNativeMappingRecovery(args); if (mappingResult !== null) { return mappingResult; @@ -777,6 +815,7 @@ const handleDoctor: CommandHandlerFor = async ({ startDir: resolveDoctorStartDir(args.options.path), browser, json, + machine: args.options.machine === true, fix: args.options.fix === true, migrateEnvConfig: args.options.migrateEnvConfig === true, acceptTrackedEnvChanges: args.options.acceptTrackedEnvChanges === true, @@ -907,8 +946,12 @@ const handleDoctor: CommandHandlerFor = async ({ results.push(await runCheck(s, DAEMON_SERVICE_CHECK, () => serviceCheck)); } } - results.push(await runCheck(s, "gateway config", () => checkGatewayConfig())); - results.push(await runCheck(s, "gateway tokens", () => checkGatewayTokens())); + results.push( + ...(await collectGatewayDoctorChecks({ + progress: s, + machine: args.options.machine === true, + })) + ); if (isMac()) { results.push( @@ -1074,31 +1117,23 @@ const handleDoctor: CommandHandlerFor = async ({ ); } - // Project (if in a repo or --path) - const startDir = args.options.path - ? resolve(process.cwd(), args.options.path) - : process.cwd(); - const projectCtx = await runCheck(s, "project", () => - checkProject({ startDir, adopted }) - ); - results.push(projectCtx); - results.push( - await runCheck(s, "sessions mux", () => - checkSessionsMuxConfig({ startDir, adopted }) - ) - ); - - results.push( - ...(await runProjectChecks({ - progress: s, - startDir, - adopted, - projectFound: projectCtx.status === "ok", - dockerOk: results.some( - (result) => result.name === "docker daemon" && result.status === "ok" - ), - })) - ); + if (args.options.machine) { + results.push( + await runCheck(s, SECRETS_HYGIENE_CHECK, checkMachineSecretsHygiene, { + timeoutMs: 10_000, + }) + ); + markMachineDoctorResults(results); + } else { + results.push( + ...(await collectDoctorProjectChecks({ + args, + progress: s, + adopted, + results, + })) + ); + } s.stop(`Doctor checks complete (${results.length} checks)`); @@ -1127,7 +1162,7 @@ const handleDoctor: CommandHandlerFor = async ({ if (args.options.fix || args.options.migrateEnvConfig) { await runDoctorFix({ - startDir, + startDir: resolveDoctorStartDir(args.options.path), migrateEnvConfig: args.options.migrateEnvConfig === true, acceptTrackedEnvChanges: args.options.acceptTrackedEnvChanges === true, }); @@ -1140,7 +1175,119 @@ const handleDoctor: CommandHandlerFor = async ({ } return 0; -}; +} + +async function collectDoctorProjectChecks(opts: { + readonly args: Parameters>[0]["args"]; + readonly progress: DoctorProgress; + readonly adopted: DoctorAdoptedProject | null; + readonly results: readonly TimedCheckResult[]; +}): Promise { + const { args, progress: s, adopted } = opts; + const results: TimedCheckResult[] = []; + const startDir = args.options.path + ? resolve(process.cwd(), args.options.path) + : process.cwd(); + const projectCtx = await runCheck(s, "project", () => + checkProject({ startDir, adopted }) + ); + results.push(projectCtx); + results.push( + await runCheck(s, "sessions mux", () => + checkSessionsMuxConfig({ startDir, adopted }) + ) + ); + + results.push( + ...(await runProjectChecks({ + progress: s, + startDir, + adopted, + projectFound: projectCtx.status === "ok", + dockerOk: opts.results.some( + (result) => result.name === "docker daemon" && result.status === "ok" + ), + })) + ); + + return results; +} + +function assertMachineDoctorOptions( + options: Readonly> +): void { + const allowed = new Set(["machine", "json"]); + if ( + Object.entries(options).some( + ([name, value]) => + !allowed.has(name) && value !== undefined && value !== false + ) + ) { + throw new CliUsageError( + "--machine accepts only --json; project selection and repair flags are not allowed." + ); + } +} + +/** The machine audit never borrows the default scope for unknown future IDs. */ +function markMachineDoctorResults(results: TimedCheckResult[]): void { + const scoped = results.map((result) => ({ + ...result, + scope: "machine" as const, + })); + results.splice(0, results.length, ...scoped); +} + +function selectedDoctorFixRequested(opts: { + readonly only?: string; + readonly yes?: boolean; +}): boolean { + return opts.only !== undefined || opts.yes === true; +} + +/** An allowlist before discovery or side effects: new Doctor flags cannot widen a selected repair. */ +async function runSelectedDoctorFix( + args: Parameters>[0]["args"] +): Promise { + const allowed = new Set(["path", "fix", "only", "yes", "json"]); + if ( + Object.entries(args.options).some( + ([key, value]) => + !allowed.has(key) && value !== undefined && value !== false + ) || + !args.options.fix || + !args.options.yes || + args.options.only !== DOCTOR_SAFE_FIX_ID + ) { + throw new CliUsageError( + "Use --fix --only 'hack gitignore' --yes for the supported safe repair; other check IDs and repair modes are refused." + ); + } + const started = performance.now(); + const fixed = await runDoctorSafeFix({ + startDir: resolveDoctorStartDir(args.options.path), + }); + const results = [ + { ...fixed.check, durationMs: Math.round(performance.now() - started) }, + ]; + if (args.options.json) { + emitCliResult({ + result: okResult({ + data: { + ...buildDoctorJsonData({ results }), + fix: { + id: DOCTOR_SAFE_FIX_ID, + changed: fixed.changed, + confirmed: fixed.confirmed, + }, + }, + }), + }); + } else { + await renderDoctorSummary(results); + } + return fixed.confirmed ? 0 : 1; +} /** * Compose doctor's browser check concerns the reported origin, else the @@ -1199,6 +1346,7 @@ async function handleNativeDoctor(opts: { readonly startDir: string; readonly browser: ReturnType; readonly json: boolean; + readonly machine: boolean; readonly fix: boolean; /** * Runs the same project-file env migration as Compose doctor after the @@ -1337,9 +1485,7 @@ async function handleNativeDoctor(opts: { } // The env key audit is runtime-independent; outside a project there is no // key to audit, so native Doctor adds no check there. - const nativeProject = await findProjectContext(opts.startDir).catch( - () => null - ); + const nativeProject = await nativeDoctorProject(opts); if (nativeProject) { results.push( await runCheck( @@ -1401,6 +1547,7 @@ async function handleNativeDoctor(opts: { ) ); } + await appendNativeMachineChecks({ ...opts, progress, results }); progress.stop(`Native doctor checks complete (${results.length} checks)`); const hasError = results.some((result) => result.status === "error"); const reported = await reportDoctorResults({ @@ -1423,6 +1570,34 @@ async function handleNativeDoctor(opts: { return hasError ? 1 : 0; } +async function nativeDoctorProject(opts: { + readonly machine: boolean; + readonly startDir: string; +}) { + return opts.machine + ? null + : await findProjectContext(opts.startDir).catch(() => null); +} + +async function appendNativeMachineChecks(opts: { + readonly machine: boolean; + readonly progress: DoctorProgress; + readonly results: TimedCheckResult[]; +}): Promise { + if (!opts.machine) { + return; + } + opts.results.push( + await runCheck( + opts.progress, + SECRETS_HYGIENE_CHECK, + checkMachineSecretsHygiene, + { timeoutMs: 10_000 } + ) + ); + markMachineDoctorResults(opts.results); +} + /** * Native doctor's `--migrate-env-config`: the same project-file migration as * Compose doctor, including its consent for git-tracked files. It needs no @@ -1960,6 +2135,20 @@ async function checkDaemonStatus(): Promise { return buildDoctorDaemonCheck({ report, candidate: isCandidateRun() }); } +/** Retained gateway checks scan registered project configs, so a machine-only audit omits them. */ +async function collectGatewayDoctorChecks(opts: { + readonly progress: DoctorProgress; + readonly machine: boolean; +}): Promise { + if (opts.machine) { + return []; + } + return [ + await runCheck(opts.progress, "gateway config", checkGatewayConfig), + await runCheck(opts.progress, "gateway tokens", checkGatewayTokens), + ]; +} + async function checkGatewayConfig(): Promise { const resolved = await resolveGatewayConfig(); const configPath = resolveGlobalConfigPath(); @@ -3277,6 +3466,19 @@ export async function checkSecretsHygiene(opts: { }; } +/** Inspect global configuration and stores without project discovery, even from a project cwd. */ +async function checkMachineSecretsHygiene(): Promise { + const scope = await resolveLiveInputScope({ + startDir: process.cwd(), + findProject: () => Promise.resolve(null), + }); + return { + name: SECRETS_HYGIENE_CHECK, + scope: "machine", + ...describeSecretsHygiene(await inspectSecretsHygiene(scope)), + }; +} + function describeSecretsHygiene(findings: readonly SecretsHygieneFinding[]): { readonly status: "ok" | "warn" | "error"; readonly message: string; diff --git a/src/lib/doctor-metadata.ts b/src/lib/doctor-metadata.ts index 409e7440c..75bd38a20 100644 --- a/src/lib/doctor-metadata.ts +++ b/src/lib/doctor-metadata.ts @@ -119,13 +119,32 @@ export function doctorMetadata(opts: { "native root trust", "native ca repair", ].includes(opts.id); + const fix: DoctorMetadata["fix"] = { + kind: adminTrust ? "admin" : "manual", + args: opts.id === "local ca trust" ? ["global", "trust"] : [], + doc_url: DOC_URL, + }; + if (opts.id === "hack gitignore") { + return { + area, + scope, + fix: { + kind: "safe", + args: [ + "doctor", + "--fix", + "--only", + "hack gitignore", + "--yes", + "--json", + ], + doc_url: DOC_URL, + }, + }; + } return { area, scope, - fix: { - kind: adminTrust ? "admin" : "manual", - args: opts.id === "local ca trust" ? ["global", "trust"] : [], - doc_url: DOC_URL, - }, + fix, }; } diff --git a/src/lib/doctor-safe-fix.ts b/src/lib/doctor-safe-fix.ts new file mode 100644 index 000000000..a400f2392 --- /dev/null +++ b/src/lib/doctor-safe-fix.ts @@ -0,0 +1,71 @@ +import { HackCliError } from "./cli-result.ts"; +import { preflightDoctorProject } from "./doctor-adopted-project.ts"; +import { inspectHackDirGitignoreForDoctor } from "./doctor-generated-files.ts"; +import { findProjectContext } from "./project.ts"; +import { ensureHackDirGitignore } from "./project-env-config.ts"; + +export const DOCTOR_SAFE_FIX_ID = "hack gitignore"; + +/** Select an ordinary legacy project again at publication; adoption never authorizes this write. */ +async function selectedProject(startDir: string) { + if (await preflightDoctorProject({ startDir })) { + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: "Selected repair does not change adopted project inputs.", + }); + } + const project = await findProjectContext(startDir); + if (!project) { + throw new HackCliError({ + code: "E_PROJECT_NOT_FOUND", + message: "Selected repair requires a project in a Git checkout.", + }); + } + return project; +} + +/** Exactly one additive repair, followed by readback. Never calls the broad guided remediation. */ +export async function runDoctorSafeFix(opts: { readonly startDir: string }) { + const project = await selectedProject(opts.startDir); + const inspect = () => + inspectHackDirGitignoreForDoctor({ projectDir: project.projectDir }); + const before = await inspect(); + if (before === null) { + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: "Selected repair requires a Git checkout.", + }); + } + let changed = false; + if (before === "missing" || before === "outdated") { + const result = await ensureHackDirGitignore({ + projectDir: project.projectDir, + beforePublish: async () => { + const current = await selectedProject(opts.startDir); + if ( + current.projectRoot !== project.projectRoot || + current.projectDir !== project.projectDir + ) { + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: "Selected repair project changed; nothing published.", + }); + } + }, + }); + changed = result.changed; + } + const after = await inspect(); + const confirmed = after === "current"; + return { + changed, + confirmed, + check: { + name: DOCTOR_SAFE_FIX_ID, + status: confirmed ? ("ok" as const) : ("error" as const), + message: confirmed + ? "Managed ignore block verified; existing user lines preserved." + : "Selected repair could not verify the managed ignore block; unsafe, unreadable and read-only files are left unchanged.", + }, + }; +} diff --git a/tests/doctor-machine.test.ts b/tests/doctor-machine.test.ts new file mode 100644 index 000000000..e0137bdd0 --- /dev/null +++ b/tests/doctor-machine.test.ts @@ -0,0 +1,242 @@ +import { expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +const cli = resolve(import.meta.dir, "../index.ts"); + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "doctor-machine-")); + const project = join(root, "project"); + const tools = join(root, "tools"); + await mkdir(join(project, ".hack"), { recursive: true }); + await mkdir(tools); + // Ordinary discovery must refuse this native input. Machine audit must never read it. + await Bun.write( + join(project, ".hack/hack.project.json"), + "{invalid PROJECT_CANARY" + ); + await Bun.write( + join(project, ".hack/hack.config.json"), + "{invalid PROJECT_CANARY" + ); + const calls = join(root, "calls"); + const forbidden = join(root, "forbidden"); + for (const name of [ + "docker", + "hackd", + "sudo", + "security", + "systemctl", + "git", + "native-runtime", + ]) { + await Bun.write( + join(tools, name), + `#!${process.execPath}\nconst args=process.argv.slice(2);\nawait Bun.write(${JSON.stringify(calls)}, (await Bun.file(${JSON.stringify(calls)}).text().catch(()=>""))+${JSON.stringify(name)}+" "+args.join(" ")+"\\n");\nconst allowed=${JSON.stringify(name)} === "docker" && (["info","ps","inspect","version"].includes(args[0]) || args[0] === "network" && args[1] === "inspect");\nconst readOnlyService=${JSON.stringify(name)} === "systemctl" && args.join(" ") === "is-active systemd-resolved";\nif(!(allowed || readOnlyService)) await Bun.write(${JSON.stringify(forbidden)},"unexpected-effect");\nprocess.exit(1);\n` + ); + await chmod(join(tools, name), 0o700); + } + const invoke = async (flags: string[], native = false) => { + const child = Bun.spawn( + [process.execPath, "--no-env-file", cli, "doctor", ...flags], + { + cwd: project, + env: privateCliEnv({ + root, + env: { + HACK_HOME: join(root, "state"), + HACK_GLOBAL_CONFIG_PATH: join(root, "state/hack.config.json"), + PATH: `${tools}:/usr/bin:/bin`, + HACK_NO_INTERACTIVE: "1", + HACK_LOGGER: "console", + HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK: "1", + ...(native + ? { + HACK_RUNTIME_BACKEND: "native", + HACK_NATIVE_BINARY: join(tools, "native-runtime"), + HACK_NATIVE_HOME: join(root, "native-home"), + } + : {}), + }, + }), + stdout: "pipe", + stderr: "pipe", + } + ); + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + return { code, stdout, stderr, json: JSON.parse(stdout) }; + }; + return { root, project, tools, calls, forbidden, invoke }; +} + +for (const native of [false, true]) { + test(`machine-only ${native ? "native" : "Compose"} audit bypasses invalid project and starts nothing`, async () => { + const f = await fixture(); + try { + const before = await readFile(join(f.project, ".hack/hack.project.json")); + const result = await f.invoke(["--machine", "--json"], native); + expect(result.json).toMatchObject({ ok: true, data: { schema: 1 } }); + expect([0, 1]).toContain(result.code); + const checks = result.json.data.checks as { + id: string; + scope: string; + status: string; + }[]; + expect(checks.length).toBeGreaterThan(0); + expect(checks.every((check) => check.scope === "machine")).toBe(true); + expect( + checks.some((check) => + [ + "project", + "env key", + "hack gitignore", + "native settings", + "lifecycle hygiene", + ].includes(check.id) + ) + ).toBe(false); + expect(checks.some((check) => check.id === "secrets hygiene")).toBe(true); + expect(result.stdout).not.toContain("PROJECT_CANARY"); + expect( + await Bun.file(f.forbidden).exists(), + await Bun.file(f.calls) + .text() + .catch(() => "no probes") + ).toBe(false); + expect( + await readFile(join(f.project, ".hack/hack.project.json")) + ).toEqual(before); + expect((await readdir(join(f.project, ".hack"))).sort()).toEqual([ + "hack.config.json", + "hack.project.json", + ]); + if (!native) { + expect( + checks.find((check) => check.id === "docker daemon")?.status + ).not.toBe("ok"); + expect(await Bun.file(f.calls).text()).toContain("docker info"); + } + expect(await Bun.file(join(f.root, "state/projects.json")).exists()).toBe( + false + ); + } finally { + await rm(f.root, { recursive: true, force: true }); + } + }, 60_000); +} + +test("machine mode refuses path selection and every mutating mode before tools run", async () => { + const f = await fixture(); + try { + for (const extra of [ + ["--path", f.project], + ["--fix"], + ["--fix", "--only", "hack gitignore", "--yes"], + ["--migrate-env-config"], + ["--native-preflight"], + ["--branch", "test"], + ["--browser-url", "https://example.hack"], + ]) { + const result = await f.invoke(["--machine", "--json", ...extra]); + expect(result.code).not.toBe(0); + expect(result.json).toMatchObject({ + ok: false, + error: { code: "E_USAGE" }, + }); + expect(await Bun.file(f.calls).exists()).toBe(false); + } + } finally { + await rm(f.root, { recursive: true, force: true }); + } +}, 30_000); + +test("machine audit excludes gateway probes even with enabled and malformed registered projects", async () => { + const f = await fixture(); + try { + const enabled = join(f.root, "registered-enabled/.hack"); + const malformed = join(f.root, "registered-malformed/.hack"); + await mkdir(enabled, { recursive: true }); + await mkdir(malformed, { recursive: true }); + await mkdir(join(f.root, "state")); + await Bun.write( + join(enabled, "hack.config.json"), + JSON.stringify({ controlPlane: { gateway: { enabled: true } } }) + ); + await Bun.write( + join(malformed, "hack.config.json"), + "{invalid REGISTERED_PROJECT_CANARY" + ); + const registry = join(f.root, "state/projects.json"); + await Bun.write( + registry, + JSON.stringify({ + version: 1, + projects: [enabled, malformed].map((projectDir, index) => ({ + id: `fixture-${index}`, + name: `registered-fixture-${index}`, + projectDir, + projectDirName: ".hack", + repoRoot: resolve(projectDir, ".."), + createdAt: "2026-01-01T00:00:00.000Z", + })), + }) + ); + const before = await readFile(registry); + // Positive control: the real resolver sees both registered checkouts and the parse warning. + const modulePath = resolve( + import.meta.dir, + "../src/control-plane/extensions/gateway/config.ts" + ); + const child = Bun.spawn( + [ + process.execPath, + "--no-env-file", + "--eval", + ` + import {resolveGatewayConfig} from ${JSON.stringify(modulePath)}; + const result = await resolveGatewayConfig(); + process.stdout.write(JSON.stringify({enabled:result.config.enabled,warning:result.warnings.some(w=>w.includes("registered-fixture-1"))})); + `, + ], + { + cwd: f.root, + env: privateCliEnv({ + root: f.root, + env: { + HACK_HOME: join(f.root, "state"), + HACK_GLOBAL_CONFIG_PATH: join(f.root, "state/hack.config.json"), + }, + }), + stdout: "pipe", + stderr: "pipe", + } + ); + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(code).toBe(0); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ enabled: true, warning: true }); + const result = await f.invoke(["--machine", "--json"]); + expect(result.json).toMatchObject({ ok: true, data: { schema: 1 } }); + const ids = result.json.data.checks.map( + (check: { id: string }) => check.id + ); + expect(ids).not.toContain("gateway config"); + expect(ids).not.toContain("gateway tokens"); + expect(result.stdout).not.toContain("registered-fixture"); + expect(result.stdout).not.toContain("REGISTERED_PROJECT_CANARY"); + expect(await readFile(registry)).toEqual(before); + expect(await Bun.file(f.forbidden).exists()).toBe(false); + } finally { + await rm(f.root, { recursive: true, force: true }); + } +}, 30_000); diff --git a/tests/doctor-safe-fix.test.ts b/tests/doctor-safe-fix.test.ts new file mode 100644 index 000000000..c359c22a5 --- /dev/null +++ b/tests/doctor-safe-fix.test.ts @@ -0,0 +1,176 @@ +import { expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + symlink, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { runFixtureGit } from "./helpers/fixture-git.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +const cli = resolve(import.meta.dir, "../index.ts"); +const selected = ["--fix", "--only", "hack gitignore", "--yes", "--json"]; + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "doctor-safe-fix-")); + const project = join(root, "project"); + await mkdir(join(project, ".hack"), { recursive: true }); + await runFixtureGit({ cwd: project, args: ["init", "--quiet"] }); + await Bun.write(join(project, ".hack/docker-compose.yml"), "services: {}\n"); + const tools = join(root, "tools"); + await mkdir(tools); + const called = join(root, "unexpected-tool"); + for (const name of ["docker", "hackd", "sudo", "security", "systemctl"]) { + await Bun.write( + join(tools, name), + `#!/bin/sh\nprintf unexpected >> '${called}'\nexit 90\n` + ); + await chmod(join(tools, name), 0o700); + } + const invoke = async (flags: string[]) => { + const child = Bun.spawn( + [ + process.execPath, + "--no-env-file", + cli, + "doctor", + "--path", + project, + ...flags, + ], + { + cwd: root, + env: privateCliEnv({ + root, + env: { + HACK_HOME: join(root, "state"), + HACK_GLOBAL_CONFIG_PATH: join(root, "state/hack.config.json"), + PATH: `${tools}:/usr/bin:/bin`, + HACK_NO_INTERACTIVE: "1", + HACK_LOGGER: "console", + }, + }), + stdout: "pipe", + stderr: "pipe", + } + ); + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(await Bun.file(called).exists()).toBe(false); + return { code, stdout, stderr, json: JSON.parse(stdout) }; + }; + return { root, project, invoke, file: join(project, ".hack/.gitignore") }; +} + +test("one safe fix preserves user lines and other findings, rechecks, and is idempotent", async () => { + const f = await fixture(); + try { + await Bun.write(f.file, "# user-owned rule\ncustom.tmp\n"); + const unrelated = join(f.project, ".hack/.env"); + await Bun.write(unrelated, "SYNTHETIC_SENTINEL=not-a-real-secret\n"); + const result = await f.invoke(selected); + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(Object.keys(result.json)).toEqual(["ok", "data"]); + expect(result.json).toMatchObject({ + ok: true, + data: { + schema: 1, + fix: { id: "hack gitignore", changed: true, confirmed: true }, + counts: { ok: 1, warn: 0, error: 0 }, + checks: [{ id: "hack gitignore", status: "ok", fix: { kind: "safe" } }], + }, + }); + expect(result.json.data.checks).toHaveLength(1); + expect(await Bun.file(f.file).text()).toContain( + "# user-owned rule\ncustom.tmp\n" + ); + expect(await Bun.file(unrelated).text()).toBe( + "SYNTHETIC_SENTINEL=not-a-real-secret\n" + ); + expect(result.stdout).not.toContain("SYNTHETIC_SENTINEL"); + const before = await readFile(f.file); + const again = await f.invoke(selected); + expect(again.code).toBe(0); + expect(again.json.data.fix).toMatchObject({ + changed: false, + confirmed: true, + }); + expect(await readFile(f.file)).toEqual(before); + } finally { + await rm(f.root, { recursive: true, force: true }); + } +}); + +test("missing consent, broad fix, admin/manual/unknown IDs and mixed modes refuse before mutation", async () => { + const f = await fixture(); + try { + for (const flags of [ + ["--fix", "--only", "hack gitignore", "--json"], + ["--only", "hack gitignore", "--yes", "--json"], + ["--fix", "--yes", "--json"], + ["--fix", "--json"], + ...["local ca trust", "daemon", "unknown", "__proto__"].map((id) => [ + "--fix", + "--only", + id, + "--yes", + "--json", + ]), + [...selected, "--migrate-env-config"], + [...selected, "--native-preflight"], + ]) { + const result = await f.invoke(flags); + expect(result.code).not.toBe(0); + expect(result.json).toMatchObject({ + ok: false, + error: { code: "E_USAGE" }, + }); + expect(await Bun.file(f.file).exists()).toBe(false); + expect(await readdir(join(f.project, ".hack"))).toEqual([ + "docker-compose.yml", + ]); + } + } finally { + await rm(f.root, { recursive: true, force: true }); + } +}); + +for (const mode of ["read-only", "symlink"] as const) { + test(`a ${mode} ignore file is retained and cannot be reported fixed`, async () => { + const f = await fixture(); + try { + const target = join(f.root, "user-content"); + await Bun.write(target, "user content\n"); + if (mode === "symlink") { + await symlink(target, f.file); + } else { + await Bun.write(f.file, "user rule\n"); + await chmod(f.file, 0o400); + } + const result = await f.invoke(selected); + expect(result.code).toBe(1); + expect(result.json).toMatchObject({ + ok: true, + data: { + fix: { changed: false, confirmed: false }, + checks: [{ status: "error" }], + }, + }); + expect(await Bun.file(target).text()).toBe("user content\n"); + expect(await Bun.file(f.file).text()).toBe( + mode === "symlink" ? "user content\n" : "user rule\n" + ); + } finally { + await rm(f.root, { recursive: true, force: true }); + } + }); +}