From 620003e4c89fdf77850d1250baf1916b46586ea3 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:07:24 +0000 Subject: [PATCH 1/8] feat(doctor): add versioned per-check metadata (R7a) --- apps/desktop/DESIGN.md | 4 +- docs/versions/v5/troubleshooting.md | 2 + src/commands/doctor.ts | 22 +++-- src/lib/doctor-metadata.ts | 131 ++++++++++++++++++++++++++++ tests/doctor-json.test.ts | 8 +- tests/doctor-metadata.test.ts | 74 ++++++++++++++++ 6 files changed, 232 insertions(+), 9 deletions(-) create mode 100644 src/lib/doctor-metadata.ts create mode 100644 tests/doctor-metadata.test.ts diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index c8b20a79c..850b31c3e 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -199,7 +199,7 @@ is removed after use. The app never types into an existing terminal or pane. Source: `hack doctor --json --path ` per registered project, plus one run outside any project for machine checks (until R7c), on open and every 10 -minutes while visible. Today it gives `checks[{id,status,detail}]`. +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 | |---|---| @@ -375,7 +375,7 @@ Each is small enough for one PR. 6. **R5a** Activity writer: append-only `activity.jsonl` in the Hack home, one line per `up`/`down`/`restart`/autostart (`ts, project, branch, action, origin, outcome, failed_services, duration_ms`), `O_APPEND` writes under one line size limit, rotation at a size cap, `origin` from `HACK_ORIGIN` (the app sets `app`). 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** Doctor metadata (a new doctor schema): per check `area`, `scope: machine | project`, `fix: {kind: safe | admin | manual, args[], doc_url}`. +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. 12. **R8** Daemon unit staleness: `service.unit_stale` and reason in `hack daemon status --json`, and `version` there. diff --git a/docs/versions/v5/troubleshooting.md b/docs/versions/v5/troubleshooting.md index 8f32405f7..a76eac28b 100644 --- a/docs/versions/v5/troubleshooting.md +++ b/docs/versions/v5/troubleshooting.md @@ -6,6 +6,8 @@ native, use the full `hack-next` path from [Try native](native/start.md). Read the named finding before using `--fix`; some problems need a decision or 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. + 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 3b4d6e660..76eb83c91 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -132,6 +132,11 @@ import { untrackGeneratedFiles, } from "../lib/doctor-generated-files.ts"; import { checkMacHostTlsTrust } from "../lib/doctor-host-tls.ts"; +import { + type DoctorMetadata, + type DoctorScope, + doctorMetadata, +} from "../lib/doctor-metadata.ts"; import { AMD64_EMULATION_CHECK, type Amd64EmulationReport, @@ -325,6 +330,7 @@ interface CheckResult { readonly name: string; readonly status: CheckStatus; readonly message: string; + readonly scope?: DoctorScope; /** Counts and public fingerprints only; never paths or keychain contents. */ readonly supersededRoots?: SupersededRootInventory; /** Native hack.local route state; lines and paths only, never file contents. */ @@ -1568,7 +1574,7 @@ export async function checkNativeHackLocalRoute(opts: { return results; } -export type DoctorJsonCheck = { +export type DoctorJsonCheck = DoctorMetadata & { readonly id: string; readonly status: CheckStatus; readonly detail: string; @@ -1596,6 +1602,7 @@ export type DoctorJsonCheck = { }; export type DoctorJsonData = { + readonly schema: 1; readonly checks: readonly DoctorJsonCheck[]; readonly summary: readonly string[]; readonly counts: { @@ -1616,8 +1623,10 @@ export function buildDoctorJsonData(opts: { counts[result.status] += 1; } return { + schema: 1, checks: opts.results.map((result) => ({ id: result.name, + ...doctorMetadata({ id: result.name, scope: result.scope }), status: result.status, detail: result.message, durationMs: result.durationMs, @@ -3259,10 +3268,13 @@ export async function resolveSecretsHygieneScope(opts: { export async function checkSecretsHygiene(opts: { readonly startDir: string; }): Promise { - const findings = await inspectSecretsHygiene( - await resolveSecretsHygieneScope(opts) - ); - return { name: SECRETS_HYGIENE_CHECK, ...describeSecretsHygiene(findings) }; + const scope = await resolveSecretsHygieneScope(opts); + const findings = await inspectSecretsHygiene(scope); + return { + name: SECRETS_HYGIENE_CHECK, + scope: scope.project ? "project" : "machine", + ...describeSecretsHygiene(findings), + }; } function describeSecretsHygiene(findings: readonly SecretsHygieneFinding[]): { diff --git a/src/lib/doctor-metadata.ts b/src/lib/doctor-metadata.ts new file mode 100644 index 000000000..0c1d52334 --- /dev/null +++ b/src/lib/doctor-metadata.ts @@ -0,0 +1,131 @@ +/** Stable presentation metadata. Neither detail text nor a check ID grants repair authority. */ +export type DoctorArea = + | "engine" + | "routing_dns" + | "certificates" + | "daemon" + | "env_secrets" + | "project_files" + | "native" + | "other"; + +export type DoctorScope = "machine" | "project"; +export type DoctorMetadata = { + readonly area: DoctorArea; + readonly scope: DoctorScope; + readonly fix: { + readonly kind: "safe" | "admin" | "manual"; + /** Argv after `hack`; empty means consult the documentation, not a runnable repair. */ + readonly args: readonly string[]; + readonly doc_url: string; + }; +}; + +const DOC_URL = + "https://github.com/hack-dance/hack/blob/next/docs/versions/v5/troubleshooting.md"; +const PROJECT_AREAS: Readonly> = { + project: "project_files", + "sessions mux": "project_files", + "generated files": "project_files", + "hack gitignore": "project_files", + "runtime hygiene": "engine", + "lifecycle hygiene": "engine", + "worktree instances": "engine", + "compose networks": "routing_dns", + "caddy hosts": "routing_dns", + DEV_HOST: "routing_dns", + "env mode": "env_secrets", + "env materialization": "env_secrets", + "env overlay warnings": "env_secrets", + "worktree keys": "env_secrets", + "env key": "env_secrets", + "secret integrity": "env_secrets", + "secrets hygiene": "env_secrets", + "compose env at rest": "env_secrets", + "compose backend features": "native", + "native data services": "native", + "amd64 emulation": "native", + "native settings": "native", + "native process wrappers": "native", + "native source size": "native", +}; +const MACHINE_AREAS: Readonly> = { + bun: "engine", + docker: "engine", + "docker compose": "engine", + "docker daemon": "engine", + "brew (optional)": "engine", + "dnsmasq (optional)": "routing_dns", + "mkcert (optional)": "certificates", + "gum (optional)": "other", + "fzf (optional)": "other", + "tmux (sessions)": "engine", + mutagen: "engine", + "go (optional)": "engine", + "caddy (optional)": "routing_dns", + "asdf (optional)": "other", + "global files": "routing_dns", + daemon: "daemon", + "daemon service": "daemon", + "gateway config": "other", + "gateway tokens": "other", + "linux dns route": "routing_dns", + grafana: "other", + "proxy ports": "routing_dns", + "proxy network": "routing_dns", + "coredns forwarding": "routing_dns", + "caddy local ca": "certificates", + "local ca trust": "certificates", + "host tls trust": "certificates", + "superseded caddy roots": "certificates", + "browser local network": "routing_dns", + "native https owner": "native", + "native root trust": "certificates", + "native ca repair": "certificates", + "native https route": "native", + "native host dns": "routing_dns", + "native hack.local route": "routing_dns", + "native hack.local repair": "routing_dns", +}; + +/** Closed IDs and prefixes only; never parse executable instructions from diagnostic text. */ +export function doctorMetadata(opts: { + readonly id: string; + /** Mixed project/global checks set their actual scope at the observation site. */ + readonly scope?: DoctorScope; +}): DoctorMetadata { + const projectArea = Object.hasOwn(PROJECT_AREAS, opts.id) + ? PROJECT_AREAS[opts.id] + : undefined; + const machineArea = Object.hasOwn(MACHINE_AREAS, opts.id) + ? MACHINE_AREAS[opts.id] + : undefined; + let area = projectArea ?? machineArea ?? "other"; + const network = [ + "network:", + "resolver:", + "dnsmasq.conf:", + "dnsmasq:", + "dns:", + ].some((prefix) => opts.id.startsWith(prefix)); + if (network) { + area = "routing_dns"; + } + const knownMachine = Object.hasOwn(MACHINE_AREAS, opts.id) || network; + const scope = opts.scope ?? (knownMachine ? "machine" : "project"); + const adminTrust = [ + "local ca trust", + "host tls trust", + "native root trust", + "native ca repair", + ].includes(opts.id); + return { + area, + scope, + fix: { + kind: adminTrust ? "admin" : "manual", + args: opts.id === "local ca trust" ? ["global", "trust"] : [], + doc_url: DOC_URL, + }, + }; +} diff --git a/tests/doctor-json.test.ts b/tests/doctor-json.test.ts index 8e9099779..3e692c6a3 100644 --- a/tests/doctor-json.test.ts +++ b/tests/doctor-json.test.ts @@ -26,7 +26,8 @@ test("buildDoctorJsonData maps checks to id/status/detail and counts", () => { ], }); - expect(data.checks).toEqual([ + expect(data.schema).toBe(1); + expect(data.checks.map(({ area, scope, fix, ...check }) => check)).toEqual([ { id: "docker", status: "ok", @@ -79,7 +80,7 @@ test("buildDoctorJsonData adds only superseded-root counts and fingerprints", () { name: "bun", status: "ok", message: "/usr/bin/bun", durationMs: 2 }, ], }); - expect(data.checks[0]).toStrictEqual({ + expect(data.checks[0]).toMatchObject({ id: "superseded caddy roots", status: "warn", detail: "2 superseded Caddy roots remain", @@ -88,6 +89,9 @@ test("buildDoctorJsonData adds only superseded-root counts and fingerprints", () }); expect(Object.keys(data.checks[1] ?? {})).toEqual([ "id", + "area", + "scope", + "fix", "status", "detail", "durationMs", diff --git a/tests/doctor-metadata.test.ts b/tests/doctor-metadata.test.ts new file mode 100644 index 000000000..6a16fc614 --- /dev/null +++ b/tests/doctor-metadata.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from "bun:test"; +import { buildDoctorJsonData } from "../src/commands/doctor.ts"; +import { okResult } from "../src/lib/cli-result.ts"; +import { doctorMetadata } from "../src/lib/doctor-metadata.ts"; + +test("doctor metadata distinguishes machine and project findings", () => { + for (const id of [ + "docker", + "network:hack-dev subnet", + "resolver:hack", + "daemon service", + ]) { + expect(doctorMetadata({ id }).scope).toBe("machine"); + } + for (const id of [ + "project", + "env key", + "hack gitignore", + "native settings", + "lifecycle hygiene", + ]) { + expect(doctorMetadata({ id }).scope).toBe("project"); + } + expect(doctorMetadata({ id: "local ca trust" })).toMatchObject({ + area: "certificates", + scope: "machine", + fix: { kind: "admin", args: ["global", "trust"] }, + }); + expect( + doctorMetadata({ id: "secrets hygiene", scope: "machine" }).scope + ).toBe("machine"); +}); + +test("unknown checks and inherited object names offer no executable repair", () => { + for (const id of [ + "new future check", + "__proto__", + "constructor", + "toString", + ]) { + expect(doctorMetadata({ id })).toMatchObject({ + area: "other", + scope: "project", + fix: { kind: "manual", args: [] }, + }); + } +}); + +test("versioned doctor audit keeps schema inside data and never parses repair instructions", () => { + const data = buildDoctorJsonData({ + results: [ + { + name: "env key", + status: "error", + message: "run: sudo injected-command", + durationMs: 0, + }, + ], + }); + expect(data).toMatchObject({ + schema: 1, + counts: { error: 1 }, + checks: [ + { + area: "env_secrets", + scope: "project", + fix: { kind: "manual", args: [] }, + }, + ], + }); + const envelope = okResult({ data }); + expect(Object.keys(envelope)).toEqual(["ok", "data"]); + expect(envelope.data.schema).toBe(1); +}); From f884ba36e4fe8a6da7cabd1939ee82b3c7b9f1ea Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:10:32 +0000 Subject: [PATCH 2/8] test(doctor): pin mixed-scope metadata observation --- apps/desktop/DESIGN.md | 7 +++-- tests/doctor-metadata.test.ts | 55 +++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 850b31c3e..32847ed5b 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -209,9 +209,10 @@ minutes while visible. The versioned audit gives `{ok:true,data:{schema:1,checks | One action | Run safe fix (R7b) · Open Terminal (admin) · Copy command · Open doc | Machine findings are de-duplicated by check `id` and shown once; project -findings link from that project's header. Until R7a adds `area`, `scope` and -`fix`, `core` maps known `id`s to areas and shows unknown ones under "Other" -with only Copy and Open doc. No Run button appears without a `fix.kind: safe`. +findings link from that project's header. Schema 1 supplies `area`, `scope` and +`fix`; older CLI versions still need `core`'s known-ID mapping. Unknown checks +appear under "Other" with only Copy and Open doc. No Run button appears without +a `fix.kind: safe`. ## 4. Upgrades diff --git a/tests/doctor-metadata.test.ts b/tests/doctor-metadata.test.ts index 6a16fc614..c1b15b8d4 100644 --- a/tests/doctor-metadata.test.ts +++ b/tests/doctor-metadata.test.ts @@ -1,7 +1,11 @@ import { expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; import { buildDoctorJsonData } from "../src/commands/doctor.ts"; import { okResult } from "../src/lib/cli-result.ts"; import { doctorMetadata } from "../src/lib/doctor-metadata.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; test("doctor metadata distinguishes machine and project findings", () => { for (const id of [ @@ -72,3 +76,54 @@ test("versioned doctor audit keeps schema inside data and never parses repair in expect(Object.keys(envelope)).toEqual(["ok", "data"]); expect(envelope.data.schema).toBe(1); }); + +test("secrets hygiene reports its actual scope inside and outside a project", async () => { + const root = await mkdtemp(join(tmpdir(), "doctor-metadata-")); + try { + const project = join(root, "project"); + await mkdir(join(project, ".hack"), { recursive: true }); + await Bun.write( + join(project, ".hack/docker-compose.yml"), + "services: {}\n" + ); + const modulePath = resolve(import.meta.dir, "../src/commands/doctor.ts"); + const child = Bun.spawn( + [ + process.execPath, + "--no-env-file", + "--eval", + ` + import {checkSecretsHygiene} from ${JSON.stringify(modulePath)}; + const checks = []; + for (const startDir of process.argv.slice(1)) checks.push(await checkSecretsHygiene({startDir})); + process.stdout.write(JSON.stringify(checks.map(check => check.scope))); + `, + root, + project, + ], + { + cwd: root, + env: privateCliEnv({ + root, + env: { + HACK_HOME: join(root, "state"), + HACK_GLOBAL_CONFIG_PATH: join(root, "state/hack.config.json"), + HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK: "1", + }, + }), + stdout: "pipe", + stderr: "pipe", + } + ); + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(stderr).toBe(""); + expect(code).toBe(0); + expect(JSON.parse(stdout)).toEqual(["machine", "project"]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); From 98f7178166f018e5f40c9c01bd6c65c97e4edfdc Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:15:58 +0000 Subject: [PATCH 3/8] feat(doctor): run one consented safe fix and verify it (R7b) --- apps/desktop/DESIGN.md | 2 +- docs/reference/cli.md | 2 + docs/versions/v5/troubleshooting.md | 2 + src/commands/doctor.ts | 79 ++++++++++++- src/lib/doctor-metadata.ts | 29 ++++- src/lib/doctor-safe-fix.ts | 71 +++++++++++ tests/doctor-safe-fix.test.ts | 176 ++++++++++++++++++++++++++++ 7 files changed, 354 insertions(+), 7 deletions(-) create mode 100644 src/lib/doctor-safe-fix.ts create mode 100644 tests/doctor-safe-fix.test.ts diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 32847ed5b..b419bfabd 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -377,7 +377,7 @@ 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`. +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** Machine-only doctor: `hack doctor --machine --json` (no project), so the app does not need to de-duplicate. 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. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index af5b832cf..8980a6129 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1623,6 +1623,8 @@ hack doctor [options] | --- | --- | | `--path, -p ` | Run a project command against a repo path (overrides cwd search) | | `--fix` | Attempt safe auto-remediations (network + CoreDNS + CA) | +| `--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..358cdb8fa 100644 --- a/docs/versions/v5/troubleshooting.md +++ b/docs/versions/v5/troubleshooting.md @@ -8,6 +8,8 @@ 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. + 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..f9c17b8f5 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,19 @@ const optBrowserResult = defineOption({ const doctorOptions = [ optPath, optFix, + 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,6 +758,15 @@ async function runNativePreflightMode( const handleDoctor: CommandHandlerFor = async ({ args, }): Promise => { + if (selectedDoctorFixRequested(args.options)) { + return await runSelectedDoctorFix(args); + } + return await runDoctorAudit(args); +}; + +async function runDoctorAudit( + args: Parameters>[0]["args"] +): Promise { if (args.options.nativePreflight) { return await runNativePreflightMode(args); } @@ -1140,7 +1166,58 @@ const handleDoctor: CommandHandlerFor = async ({ } return 0; -}; +} + +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 diff --git a/src/lib/doctor-metadata.ts b/src/lib/doctor-metadata.ts index 0c1d52334..f0f94a17e 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-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 }); + } + }); +} From 954804f777e7dea5285fc8410493d879afd78803 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:21:46 +0000 Subject: [PATCH 4/8] feat(doctor): observe machine checks without project discovery (R7c) --- apps/desktop/DESIGN.md | 6 +- docs/reference/cli.md | 1 + docs/versions/v5/troubleshooting.md | 2 + src/commands/doctor.ts | 183 ++++++++++++++++++++++------ tests/doctor-machine.test.ts | 157 ++++++++++++++++++++++++ 5 files changed, 308 insertions(+), 41 deletions(-) create mode 100644 tests/doctor-machine.test.ts diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index b419bfabd..1e8ace317 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 | @@ -378,7 +378,7 @@ Each is small enough for one PR. 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 — 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** Machine-only doctor: `hack doctor --machine --json` (no project), so the app does not need to de-duplicate. +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 8980a6129..3a24803fc 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1623,6 +1623,7 @@ 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 | diff --git a/docs/versions/v5/troubleshooting.md b/docs/versions/v5/troubleshooting.md index 358cdb8fa..324f3e8aa 100644 --- a/docs/versions/v5/troubleshooting.md +++ b/docs/versions/v5/troubleshooting.md @@ -10,6 +10,8 @@ an original key, not an automatic 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. 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 f9c17b8f5..149b4d0ad 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -434,6 +434,13 @@ 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", @@ -758,24 +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); } - return await runDoctorAudit(args); -}; - -async function runDoctorAudit( - args: Parameters>[0]["args"] -): Promise { 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; @@ -803,6 +815,7 @@ async function runDoctorAudit( 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, @@ -1100,31 +1113,23 @@ async function runDoctorAudit( ); } - // 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)`); @@ -1153,7 +1158,7 @@ async function runDoctorAudit( 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, }); @@ -1168,6 +1173,67 @@ async function runDoctorAudit( 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; @@ -1276,6 +1342,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 @@ -1414,9 +1481,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( @@ -1478,6 +1543,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({ @@ -1500,6 +1566,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 @@ -3354,6 +3448,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/tests/doctor-machine.test.ts b/tests/doctor-machine.test.ts new file mode 100644 index 000000000..d5f3aaf72 --- /dev/null +++ b/tests/doctor-machine.test.ts @@ -0,0 +1,157 @@ +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); From e27d8998449677058cac4d75ee4c551636bdb952 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:22:28 +0000 Subject: [PATCH 5/8] fix(doctor): preserve project scope for exact-target findings --- apps/desktop/DESIGN.md | 3 +++ src/lib/doctor-metadata.ts | 6 +++--- tests/doctor-metadata.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 32847ed5b..11c6e2336 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -214,6 +214,9 @@ findings link from that project's header. Schema 1 supplies `area`, `scope` and appear under "Other" with only Copy and Open doc. No Run button appears without a `fix.kind: safe`. +Browser observations and native hostname DNS/HTTPS route checks are project-scoped: +each describes the selected exact target and must not be de-duplicated across projects. + ## 4. Upgrades ![Upgrade](diagrams/upgrade.svg) ([source](diagrams/upgrade.mmd)) diff --git a/src/lib/doctor-metadata.ts b/src/lib/doctor-metadata.ts index 0c1d52334..409e7440c 100644 --- a/src/lib/doctor-metadata.ts +++ b/src/lib/doctor-metadata.ts @@ -24,6 +24,9 @@ export type DoctorMetadata = { const DOC_URL = "https://github.com/hack-dance/hack/blob/next/docs/versions/v5/troubleshooting.md"; const PROJECT_AREAS: Readonly> = { + "browser local network": "routing_dns", + "native https route": "native", + "native host dns": "routing_dns", project: "project_files", "sessions mux": "project_files", "generated files": "project_files", @@ -78,12 +81,9 @@ const MACHINE_AREAS: Readonly> = { "local ca trust": "certificates", "host tls trust": "certificates", "superseded caddy roots": "certificates", - "browser local network": "routing_dns", "native https owner": "native", "native root trust": "certificates", "native ca repair": "certificates", - "native https route": "native", - "native host dns": "routing_dns", "native hack.local route": "routing_dns", "native hack.local repair": "routing_dns", }; diff --git a/tests/doctor-metadata.test.ts b/tests/doctor-metadata.test.ts index c1b15b8d4..8f322a3b6 100644 --- a/tests/doctor-metadata.test.ts +++ b/tests/doctor-metadata.test.ts @@ -50,6 +50,28 @@ test("unknown checks and inherited object names offer no executable repair", () } }); +test("different browser and hostname targets retain independent project findings", () => { + for (const id of [ + "browser local network", + "native host dns", + "native https route", + ]) { + for (const status of ["ok", "error"] as const) { + const data = buildDoctorJsonData({ + results: [ + { + name: id, + status, + message: "An exact target was observed", + durationMs: 0, + }, + ], + }); + expect(data.checks[0]).toMatchObject({ id, status, scope: "project" }); + } + } +}); + test("versioned doctor audit keeps schema inside data and never parses repair instructions", () => { const data = buildDoctorJsonData({ results: [ From 53e23d47ac58b496904d5bdb938348fc2d9f8de0 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:31:57 +0000 Subject: [PATCH 6/8] fix(doctor): omit project-dependent gateway checks in machine mode --- docs/versions/v5/troubleshooting.md | 2 +- src/commands/doctor.ts | 22 +++++++- tests/doctor-machine.test.ts | 85 +++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/docs/versions/v5/troubleshooting.md b/docs/versions/v5/troubleshooting.md index 324f3e8aa..636dbe35b 100644 --- a/docs/versions/v5/troubleshooting.md +++ b/docs/versions/v5/troubleshooting.md @@ -10,7 +10,7 @@ an original key, not an automatic 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. 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. +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 diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 149b4d0ad..0307038b1 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -946,8 +946,12 @@ async function runDoctorAudit( 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( @@ -2131,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(); diff --git a/tests/doctor-machine.test.ts b/tests/doctor-machine.test.ts index d5f3aaf72..e0137bdd0 100644 --- a/tests/doctor-machine.test.ts +++ b/tests/doctor-machine.test.ts @@ -155,3 +155,88 @@ test("machine mode refuses path selection and every mutating mode before tools r 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); From 34ba0248eedb42255e846168b5af31195b2e2521 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:37:43 +0000 Subject: [PATCH 7/8] test(doctor): expect metadata on adopted project findings --- tests/doctor-adopted-project.test.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/doctor-adopted-project.test.ts b/tests/doctor-adopted-project.test.ts index b0760f6ba..a88ac7335 100644 --- a/tests/doctor-adopted-project.test.ts +++ b/tests/doctor-adopted-project.test.ts @@ -99,12 +99,20 @@ test("doctor reports an adopted project with closed messages instead of refusing expect(run.stdout + run.stderr).not.toContain("E_NATIVE_PROJECT"); expect(run.stdout + run.stderr).not.toContain(SOURCE_BIND_CANARY); const parsed: unknown = JSON.parse(run.stdout); - expect(parsed).toMatchObject({ ok: true }); + expect(parsed).toMatchObject({ ok: true, data: { schema: 1 } }); const checks = (parsed as { data: { checks: JsonCheck[] } }).data.checks; const byId = new Map(checks.map((check) => [check.id, check])); expect(byId.get("project")).toEqual({ id: "project", + area: "project_files", + scope: "project", + fix: { + kind: "manual", + args: [], + doc_url: + "https://github.com/hack-dance/hack/blob/next/docs/versions/v5/troubleshooting.md", + }, status: "ok", detail: `Adopted Compose project at ${join(h.root, ".hack")} ('hack config adopt'). Its Compose inputs belong to the adoption; adopted commands support retained-container start, restart, stop, ps, logs and exec.`, durationMs: expect.any(Number), @@ -198,11 +206,19 @@ test("doctor reports a pending adoption as a project warning that asks for recov expect(run.stdout + run.stderr).not.toContain(SOURCE_BIND_CANARY); const parsed: unknown = JSON.parse(run.stdout); // A warning, not an error: doctor still exits ok. - expect(parsed).toMatchObject({ ok: true }); + expect(parsed).toMatchObject({ ok: true, data: { schema: 1 } }); const checks = (parsed as { data: { checks: JsonCheck[] } }).data.checks; const byId = new Map(checks.map((check) => [check.id, check])); expect(byId.get("project")).toEqual({ id: "project", + area: "project_files", + scope: "project", + fix: { + kind: "manual", + args: [], + doc_url: + "https://github.com/hack-dance/hack/blob/next/docs/versions/v5/troubleshooting.md", + }, status: "warn", detail: `Adopted Compose project at ${join(h.root, ".hack")} has a pending or interrupted adoption operation. Use explicit adoption recovery before other project commands; values omitted.`, durationMs: expect.any(Number), From 6e27a9a2958b2fe29b16d7ab60f9339876c66cd6 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:43:49 +0000 Subject: [PATCH 8/8] test(doctor): reuse the exported JSON check contract --- tests/doctor-adopted-project.test.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/tests/doctor-adopted-project.test.ts b/tests/doctor-adopted-project.test.ts index a88ac7335..9de376516 100644 --- a/tests/doctor-adopted-project.test.ts +++ b/tests/doctor-adopted-project.test.ts @@ -1,7 +1,10 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; -import { buildDoctorRemediationPlanLines } from "../src/commands/doctor.ts"; +import { + buildDoctorRemediationPlanLines, + type DoctorJsonCheck, +} from "../src/commands/doctor.ts"; import { ADOPTED_PROJECT_NOT_APPLICABLE_CHECKS, ADOPTED_PROJECT_NOT_APPLICABLE_MESSAGE, @@ -85,13 +88,6 @@ async function doctorJson(): Promise<{ return { exit, stdout, stderr }; } -type JsonCheck = { - readonly id: string; - readonly status: string; - readonly detail: string; - readonly durationMs: number; -}; - test("doctor reports an adopted project with closed messages instead of refusing it as native input (HACK-1311)", async () => { await adopt(); const receipt = await readFile(receiptPath(), "utf8"); @@ -100,7 +96,8 @@ test("doctor reports an adopted project with closed messages instead of refusing expect(run.stdout + run.stderr).not.toContain(SOURCE_BIND_CANARY); const parsed: unknown = JSON.parse(run.stdout); expect(parsed).toMatchObject({ ok: true, data: { schema: 1 } }); - const checks = (parsed as { data: { checks: JsonCheck[] } }).data.checks; + const checks = (parsed as { data: { checks: DoctorJsonCheck[] } }).data + .checks; const byId = new Map(checks.map((check) => [check.id, check])); expect(byId.get("project")).toEqual({ @@ -207,7 +204,8 @@ test("doctor reports a pending adoption as a project warning that asks for recov const parsed: unknown = JSON.parse(run.stdout); // A warning, not an error: doctor still exits ok. expect(parsed).toMatchObject({ ok: true, data: { schema: 1 } }); - const checks = (parsed as { data: { checks: JsonCheck[] } }).data.checks; + const checks = (parsed as { data: { checks: DoctorJsonCheck[] } }).data + .checks; const byId = new Map(checks.map((check) => [check.id, check])); expect(byId.get("project")).toEqual({ id: "project",