Skip to content

feat(native): repair the hack.local DNS route to the native relay - #316

Merged
roodboi merged 16 commits into
nextfrom
wf/hack-local-native-repair-b11
Oct 12, 2026
Merged

roodboi merged 16 commits into
nextfrom
wf/hack-local-native-repair-b11

Conversation

@roodboi

@roodboi roodboi commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Product decision (2026-10-12): keep hack.local as the default dev-host root. Under the native backend, the HTTPS relay listens on 127.0.0.1, but a Docker-era install routes .hack.local to Docker Caddy (address=/.hack.local/172.30.0.2). This adds an authorized, reversible native repair.

  • Doctor reporting: native hack doctor on macOS adds a native hack.local route check. --json carries a hackLocalRoute object (status, closed reason, old/new line, resolver action, relay port, receipt state, next command). Details name lines by number. They never repeat file contents, and control characters are replaced.
  • Repair (hack doctor --fix): rewrites only the single Hack-managed address=/.hack.local/<old> line to 127.0.0.1 in place. It never adds a second line. .hack, .hack.gy and every other byte stay the same, including a leading UTF-8 BOM. It creates /etc/resolver/hack.local (nameserver 127.0.0.1) only when the file is missing.
  • Preconditions: each unmet precondition is a closed refusal reason:
    • relay-not-verified: the relay owner must be verified via inspectActiveNativeHttpsOwner, and its receipted port must accept a TCP connection on 127.0.0.1.
    • no-managed-line, unmanaged-line: exactly one exact-format line must exist, with no include-dir or subdomain claims.
    • foreign-target: the target must be one Hack wrote (172.30.0.2 or ::1).
    • old-target-live: the old target must not answer on :443 within 1.5 s.
    • foreign-resolver
    • pending-receipt, invalid-receipt
    • non-interactive: this refusal names hack doctor --fix.
    • declined
    • busy: another repair or rollback holds the lock.
    • restart-unavailable: the dnsmasq launchd job cannot be restarted safely. This is checked after the prompt and before any effect.
    • inputs-changed: everything is inspected again after the prompt.
  • Privilege: uses the same native path as the existing native DNS and CA code. That is confirmSafe plus sudo -v for the macOS prompt, then bounded sudo -n steps through runNativeDnsAttachedCommand. The resolver is created with the existing privileged resolver script, which is now exported as runPrivilegedResolverOperation. dnsmasq restarts through the verified launchd job (prepareNativeDnsLaunchdRestart), then the DNS cache is flushed and live DNS is verified with dig and dscacheutil. Hack never handles a password.
  • Receipt and lock: <native-home>/native-dns/hack-local-route.json (0600, in a 0700 directory owned by the user). It records the old and new line, the SHA-256 of the exact file bytes before and after, the resolver action and identity, and timestamps. It is published as pending before any effect and becomes applied after live verification. Its old line must be one of the managed single-line forms, and its paths must be absolute and free of control characters. Repair and rollback hold an exclusive lock (hack-local-route.json.lock, published with link(); a lock whose holder pid no longer exists is taken over once).
  • Automatic cleanup: if a step fails, the cleanup restores the earlier line. It restarts dnsmasq only when the cleanup changed something or a restart was already attempted. When the failure came before any change, it reports "nothing was changed" and marks the receipt rolled-back. It never overwrites a concurrent edit: that case keeps the receipt pending.
  • Rollback: hack global dns deactivate --domain hack.local needs a terminal (non-interactive otherwise, naming the command). It restores the exact earlier line and verifies the whole-file hash. It removes the resolver only if the repair created it and its identity still matches, then restarts dnsmasq. If dnsmasq is not running or the job is ambiguous, it still restores the file and says that dnsmasq was not restarted. A receipt whose line does not reproduce its own hashes refuses with invalid-receipt and is never written.
  • Recovery cannot get stuck: sometimes dnsmasq.conf changed after the repair (for example a Docker-era hack doctor --fix rewrote the roots), or the receipt names another path. The rollback command then never writes the file. After a plain confirmation (no administrator prompt) it marks the receipt released and leaves dnsmasq.conf and any resolver as they are. That clears a pending receipt, and Doctor inspects the current file again.
  • Dead end fixed: before this change, hack global dns preview|activate|deactivate with no --domain resolved to hack.local, which the planner always refuses. Now a built-in default gives a closed CliUsageError that names --domain <custom>, hack doctor --fix and the rollback command. A configured custom default_domain still works as documented. preview --domain hack.local shows the repair proposal without changing anything. activate --domain hack.local points to hack doctor --fix.
  • Docs: docs/guides/native-candidate.md and docs/guides/init-project.md are updated, including the recovery path for a drifted receipt and a note that a non-443 HACK_NATIVE_HTTPS_PORT still needs the port in *.hack.local URLs. docs/reference/cli.md is regenerated (summary changes only; no change in this rework).

Rework after verification (head 81a8720)

This rework addresses each verifier finding on efb278e:

  • Stuck recovery: a drifted or path-mismatched receipt is now released by the rollback command, which writes nothing else.
  • BOM and hashes: readStableNativeDnsFile now decodes with ignoreBOM: true, so strings round-trip to the exact bytes. Receipt hashes equal the raw file hashes, and rollback restores identical bytes (checked with Buffer.compare).
  • Value-free diagnostics: unmanaged-line and foreign-target details no longer repeat the line or the target. Every human message and doctor message goes through printable().
  • Unclosed failures: repair refuses with restart-unavailable, and rollback reports a skipped restart instead of throwing. A pre-write failure no longer reports "automatic restore failed".
  • Receipt hardening: the old line is checked against the managed single-line forms, the paths must be absolute and control-free, and the resolver identity must be dev:ino.
  • Concurrency: an exclusive lock covers repair and rollback.
  • Merge: merged current next (3c7cf39). The only conflict was an additive one in doctor.ts (envKey and hackLocalRoute fields), and both were kept.

Validation

All on the pushed head 81a8720 (merged with next):

  • tests/native-hack-local-route.test.ts (34 tests) injects file roots in a temp directory and a fake privileged runner. The runner refuses paths outside the temp resolver directory and asserts sudo -n. Relay, probe, restart, flush and verify are also fakes. Owner checks use an injected ownerUid seam. In addition to the earlier coverage, the tests cover:
    • release of a pending receipt after a file rewrite, then current
    • BOM bytes and raw hashes
    • rollback refusing non-interactive and declined
    • rollback rechecking both the file and the receipt after authorization
    • a receipt whose line does not reproduce its hashes
    • newline injection with matching hashes, control-character paths, relative paths and bad identities
    • path-mismatch release
    • cleanup not overwriting a concurrent edit
    • restart-unavailable, and rollback with no restart
    • the busy lock and takeover of a stale lock
    • hard-linked and foreign-owned dnsmasq.conf
    • a non-private or foreign-owned receipt directory
    • hostile line and path diagnostics
    • the restart retried during cleanup
  • Mutation check: a scratch script mutated each of these 19 guards one at a time, and each mutation fails the suite (19 of 19 killed):
    • the rollback and repair non-interactive gates
    • the rollback and release rechecks
    • the restore hash check and both drift checks
    • the receipt path check
    • both lock checks and stale-lock liveness
    • dnsmasq nlink and owner
    • the receipt directory mode and owner
    • BOM preservation
    • the old-line format check
    • restart-only-when-needed and the cleanup restart retry
  • tests/native-hack-local-route.test.ts, tests/native-hack-local-route-cli.test.ts and tests/native-ca-doctor-cli.test.ts: 40 pass.
  • tests/integration-ownership.test.ts and tests/agent-plugin-generation.test.ts: 25 pass.
  • Related suites: 169 pass and 2 skip. They are cli-reference-docs, doctor-command, doctor-env-key, doctor-fix-noninteractive, doctor-json, global-command (+macos), native-ca-doctor and the native-domain-dns-* suites.
  • bun run --cwd packages/cli typecheck: exit 0. bun run --cwd packages/cli check: exit 0 with 34 warnings, none in changed files. An explicit biome check of the 8 changed TS files reports no diagnostics.
  • bun run docs:cli-reference produces no diff. No consumer guidance changed, so no plugin regeneration was needed.

Gaps

  • Not exercised against a real macOS host. The real privileged resolver create, the launchd kickstart, live dig and dscacheutil, and the Safari/Chrome/Firefox checks (including the Local Network grant) remain manual.
  • /etc/resolver must already exist for resolver creation. The privileged script does not create the directory. Machines with a prior v4 install already have it.
  • A Docker-era hack doctor --fix or hack global install still rewrites all three roots to whichever target answers, which can undo the native repair. This is documented, and the receipt can now be released afterwards.
  • Stale-lock takeover has a narrow race if two runs find the same dead holder at once. Each run needs an interactive prompt, so this window is not reachable in practice. It is documented in code.
  • Native hack doctor --fix without --browser-url still reports the existing CA-repair error. The DNS repair runs independently.
  • No hosted CI status was reported on the previous head. CI on this head has not been checked here.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

roodboi and others added 15 commits October 12, 2026 13:52
…mands

A warm, image-only, unrouted `up` or `restart` with no hooks now runs
four directory-xattr storage proofs instead of six. Each proof is three
fresh helper containers.

- The warm enrollment tail (no new volume) reuses the effect-entry proof
  of the same generation under the same lease. It uses the existing
  adjacentProof record, which needs a byte-identical material binding,
  so any receipt write in between forces a full proof.
- Finalization skips the second proof pass when there is no route,
  after hook, or new or saved file material. In that case the
  completion step only reads.

Proof reuse stays within one command and one lease. Nothing is
persisted, so every command still opens with a full proof.

Each performed verification proof now emits a passive `storage.proof`
phase-trace span. The hosted native-config-compose scenario uses it to
assert four proofs on each of two consecutive warm ups.

Refs: HACK-1123

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sed file

The HACK-1211 experiment produced two iterations per Bun version: one
focused pass and one full `bun test` that started on a 600 s estimate and
overran the 30-minute budget, because the full suite takes 30-35 minutes
on hosted macOS.

- Never start an iteration whose estimate exceeds the remaining budget.
  The estimate is a conservative prior (focused 120 s, full 2400 s) until a
  run completes, then the longest completed run times 1.2. A command that
  does not fit is recorded once in summary.json `skippedForBudget` and
  leaves the rotation. Deadline stops remain a safety net (`overrun`).
- Add `--mode=focused|full|alternate` (default focused: only
  tests/native-https-owner.test.ts) and `--role=baseline|candidate`.
- The workflow gains a validated `mode` dispatch input; the label-triggered
  matched pair uses focused with baseline/candidate roles. Full mode needs a
  budget of at least 40 minutes. The job timeout stays budget + 15.
- summary.json gains per-command counts (iterations, passes, crashes, other
  failures, overruns, skipped-for-budget) and a one-line verdict such as
  "baseline 0/97 focused crashes".
- The shared classifier also treats exit 134 (SIGABRT) and 138 (SIGBUS) as
  crash exits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Native `up` emitted a `--ready` goal for every compose service, including
services in profiles that were not selected. The runtime does not schedule
inactive services and refuses readiness for them ("Readiness includes
inactive or unknown services."), so any project with an unused profile
failed with graph_readiness.

Build readiness goals only for services the reviewed plan marks active,
and ignore dependency edges declared by inactive services so they cannot
raise an active service's goal. The runtime refusal stays as a guard; a
contract test now proves it still rejects inactive and unknown services.

Bridge pool capacity keeps counting inactive routed services: it is sized
before the profile-aware review and must remain an upper bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On macOS, print one closed hint line on stderr when a command opens or
prints a browser URL or dev host ending in .local (hack open, hack branch
open, hack status/projects, and Doctor's browser check). Browsers other
than Safari need the macOS Local Network permission once for .local names
(Apple TN3179), even when Hack's resolver answers with loopback.

The hint is shown at most once per command, never on other platforms,
never for non-.local hosts, and never inside --json payloads. Hack does
not read or change the permission.

Document the name-based rule, the Safari exemption and the alternatives
(OAuth alias, or a custom suffix under hack.gy) in the macOS browser
guide, and link it from the init and native domain docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under the native backend on macOS, `hack doctor` reports a
`native hack.local route` check (JSON `hackLocalRoute`) and
`hack doctor --fix` proposes and, after confirmation plus the native
administrator prompt, rewrites only the Hack-managed
`address=/.hack.local/<old>` dnsmasq line to 127.0.0.1 in place. It
creates /etc/resolver/hack.local with `nameserver 127.0.0.1` only when
missing. Preconditions are closed refusal reasons: verified relay owner
listening on 127.0.0.1, a single exact managed line, a Hack-written
target, a dead old target (bounded :443 probe), a Hack-owned resolver,
no pending receipt, and unchanged inputs across authorization.

A private receipt (old/new line, whole-file hashes, timestamps) is
published before effects; failures restore the earlier line. Rollback
via `hack global dns deactivate --domain hack.local` restores the exact
earlier bytes, verifies the hash and recovers interrupted repairs.

`hack global dns preview|activate|deactivate` without --domain no
longer targets a built-in default; it stops with a usage message naming
`hack doctor --fix` and the rollback command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Rollback of a receipt whose dnsmasq.conf changed (or names another path)
  no longer refuses forever: after a plain confirmation it marks the
  receipt released and writes nothing else, so a pending receipt can
  always be cleared.
- Keep a leading BOM when reading DNS files, so rewrites keep every byte
  and receipt hashes equal the raw file hashes.
- Hold an exclusive lock for repair and rollback; refuse with busy.
- Repair refuses with restart-unavailable before any effect; rollback
  restores the file even when dnsmasq cannot be restarted and says so.
  Automatic cleanup restarts only when something changed and reports
  "nothing was changed" when the failure came first.
- Accept only the managed single-line form for a receipt's old line, and
  control-free absolute paths; diagnostics name lines by number and
  replace control characters.
- Cover the rollback non-interactive, recheck, restore, path, lock, file
  owner/link and private-directory guards with focused tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k.local repair

The hack.local repair now refuses with closed reasons instead of acting on
DNS data it never inspected or throwing raw read errors:

- uninspected-config-source: dnsmasq.conf or a dnsmasq.d include loads
  conf-file, conf-script, addn-hosts, hostsdir, servers-file, or a conf-dir
  other than <brew>/etc/dnsmasq.d,*.conf. The scan is shared with the scoped
  DNS plan (findUninspectedDnsmasqSource), which now also refuses
  servers-file and conf-script.
- unreadable-config: a DNS file is a symlink (O_NOFOLLOW), invalid UTF-8,
  linked or oversized. Inspection and rollback refuse without echoing the
  read error or file content.

Adds tests for the conf-file repro, every source directive, unreadable main
and include files, rollback of an unreadable file, and the three
defense-in-depth guards: the post-write hash recheck in restore,
replaceManagedLine with zero or two matches, and the null resolver identity
guard before removal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only `hack open --json` exercised the hint, so dropping it from the
human open path, branch open, projects/status or either doctor path left
tests green.

Run `hack open`, `hack branch open`, `hack projects` and `hack status`
from source with process.platform preloaded as darwin, stub `open` and
`docker` on a hermetic PATH, and an isolated HOME/state, asserting one
hint for a .local dev host and none for a non-.local host.

Doctor probes Docker, resolvers, the keychain and /usr/bin/curl, so its
compose and native tails now share `reportDoctorResults` with an
injectable hint, and each path's targets come from exported builders
that are tested directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add a source CLI regression for a first up with new file material and no
saved file material: finalization keeps its completion step, the second
finalize pass, and the post-completion file-mount readiness check. The
test fails when the retirement condition keeps only the saved-files arm.

Native-compose CLI harnesses now give the child a private HOME under the
fixture root instead of the contributor's HOME.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicted files: none. The plain merge applied cleanly; no generated
files needed hand resolution, and the generation and reference tests
pass on the merged tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicted files: none. The plain merge resolved automatically. This
PR's change set (native readiness goals, the bridge capacity comment and
the project_contract.rs guard test) does not overlap witness reuse or
merge train 2. Regenerated plugins and docs/reference/cli.md were
byte-equal to the merged tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-hint-b11

Conflicted files: none. The merge applied cleanly; src/commands/doctor.ts
and src/commands/project.ts auto-merged with both sides' changes kept.

Regenerated surfaces (generate:agent-plugins, docs:cli-reference) produced
no further changes. src/agents/integration-revision.ts,
src/agents/integration-ownership.ts and the checked-in renders match the
predecessor branch, and their tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pair-b11

Conflicted files: none. Git auto-merged docs/guides/init-project.md,
docs/guides/native-candidate.md and src/commands/doctor.ts, keeping both
sides' content.

Regenerated plugins (generate:agent-plugins) and docs/reference/cli.md
(docs:cli-reference); both produced no further changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi changed the base branch from next to wf/local-network-hint-b11 October 12, 2026 20:53
@roodboi
roodboi marked this pull request as ready for review October 12, 2026 21:10
Base automatically changed from wf/local-network-hint-b11 to next October 12, 2026 21:53
Re-parents onto next after the previous stacked PR squashed in; next's tree equals d0c8884, which this branch already contains, so the branch tree is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi merged commit a8068ca into next Oct 12, 2026
29 of 31 checks passed
@roodboi
roodboi deleted the wf/hack-local-native-repair-b11 branch October 12, 2026 21:54
@blacksmith-sh

blacksmith-sh Bot commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Found 1 test failure on Blacksmith runners:

Failure

Test View Logs
native-config-process-policy (36.4s) — Native --profile must succeed/
native-config-process-policy (36.4s) — Native --profile must succeed
View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

roodboi added a commit that referenced this pull request Oct 13, 2026
## Problem

On a real macOS desktop Terminal, the native candidate built from `next` at 63b614f ran
`hack-v5 doctor --fix`. It showed the hack.local repair confirmation (#316), and the user
answered yes. The output then printed `sudo: a password is required` without ever showing a
password prompt. The repair then reported `Repair refused (declined): The hack.local repair was
not authorized; nothing changed.`

## Root cause

- **The spinner erased the prompt.** `handleNativeDoctor` starts one clack spinner ("Checking
  the active native HTTPS root...") and keeps it running through every check, including the
  `--fix` repairs. The spinner redraws every 80 ms with `ESC[1G ESC[J` (go to column 1, erase to
  the end of the screen). sudo's `Password:` prompt was erased within one frame. I reproduced
  this in a pseudo-terminal with a fake `sudo` on `PATH`: the transcript shows `Password:`
  followed at once by `ESC[1G ESC[J` and a new spinner frame.
- **Process groups were not the cause.** `runWithTerminalGroup` correctly made the sudo child
  the terminal's foreground process group: in the reproduction, `tpgid == pgid` for the sudo
  child.
- **No `sudo -n` ran before the prompt.** Every `sudo -n` step in the repair (resolver script,
  launchctl kickstart, cache flush) runs only after `authorize` succeeds, and its stderr is
  captured to files.
- **Where the message came from.** In sudo 1.9.17p2 (the version on m3), `log_auth_failure`
  prints `a password is required` for any authentication failure without `FLAG_BAD_PASSWORD`.
  That includes a first password read that was interrupted or timed out (`AUTH_INTR` with
  `ntries == 0`). So the user, seeing only a spinner, was left at an invisible prompt until the
  120 s `run` timeout (or an interrupt) cancelled it. sudo then printed that line.
- **Why it said "declined".** `authorize` returned `false` for any non-zero `sudo -v` exit, and
  the repair mapped `false` to `declined`.

## Fix

- `src/lib/terminal-handoff.ts`: `pausableSpinner` and `withTerminalHandoff`. A repair claims
  the terminal at its first prompt. The spinner is erased and stays off through the
  administrator prompt and every later `sudo -n` step, then resumes once (also on throw). Doctor
  uses this for both the hack.local repair and the native CA repair (extracted as
  `repairNativeCaTrustCheck`). Repairs were already run one at a time; no doctor step runs at
  the same time as a repair.
- `src/lib/admin-authorization.ts`: one shared `sudo -v` helper for the hack.local
  repair/rollback, the native CA repair and native `global dns activate/deactivate`. It refuses
  before running sudo when Hack is not the terminal's foreground job. It classifies a failure as
  `timed-out`, `interrupted` or `rejected` and never uses `-n`.
- The hack.local `authorize` dependency now returns `approved | declined | authorization-
  failed`. A new closed refusal, `authorization-failed`, names the cause and suggests `Run "sudo
  -v" in this terminal first, then rerun: …`. Only answering no is `declined`. The CA repair and
  DNS activation throw errors that name the cause and include the same hint, instead of "was not
  completed" or "was denied".
- `docs/guides/native-candidate.md`: documents that the spinner pauses, the new refusal reason,
  and the sudo message.

## Validation

- Focused suites, `tests/integration-ownership.test.ts`, the native DNS activate/deactivate/host
  suites, `global-command.macos`, `doctor-command` and `prerelease-plan`: 229 pass, 2 skip, 0
  fail.
- `bun run --cwd packages/cli typecheck`: clean. `bun run --cwd packages/cli check`: 0 errors.
  Its 34 warnings are all in files this PR does not touch; the changed files have no
  diagnostics.
- Tests inject runners, terminal-foreground state and spinners. They never run real sudo or
  osascript, and never touch system files or the keychain. They cover:
  - the spinner is paused before the prompt and resumed after;
  - the prompt runs before every privileged `sudo -n` step;
  - a failed `sudo -v` is `authorization-failed`, not `declined`;
  - a no at the confirmation never reaches sudo;
  - a non-interactive run declines without prompting or pausing;
  - a background run refuses before sudo.
- Mutation check: I made 13 targeted changes that each break the fix (skip the handoff in either
  repair, map a failure to `declined`, prompt for sudo before confirming, drop the foreground
  check, pass `-n`, misclassify a timeout, never resume the spinner, prepare the restart before
  authorizing, install after a failed prompt). The tests caught all 13.
- Manual check in a pseudo-terminal with a fake `sudo` (not real sudo): before the fix, the
  spinner wiped `Password:`. After the fix, `Password:` stays visible and the spinner resumes
  only after sudo exits.

## Remaining gaps

- Not yet run against real sudo in a desktop Terminal. Rebuild the candidate from this branch
  and run `doctor --fix` on m3 to confirm the real prompt (or Touch ID sheet, since `pam_tid` is
  enabled there) is visible and the repair completes.
- Draft only; not merged or released.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant