Skip to content

fix(daemon): never autostart the stable hackd from a candidate run - #416

Merged
roodboi merged 8 commits into
nextfrom
wf/g49-candidate-no-stable-hackd
Oct 23, 2026
Merged

roodboi merged 8 commits into
nextfrom
wf/g49-candidate-no-stable-hackd

Conversation

@roodboi

@roodboi roodboi commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Root cause

A candidate run could start the stable hackd in four ways. Each runs whatever hackd the host
resolves, not the running CLI.

  1. requestDaemonJson autostarts hackd by default when it runs in an interactive TTY:

    • on macOS with the default Hack home, through the per-user launchd plist, which points at the
      stable binary;
    • otherwise through startDaemonViaCli, where resolveHackInvocation picks the first hack on
      PATH, which is the stable CLI.

    So a candidate command such as HACK_RUNTIME_BACKEND=compose hack-next ps --json (the daemon
    query in src/commands/project.ts) could start the stable hackd.

  2. hack global up runs hack daemon start through resolveHackInvocation whenever
    daemon.autoStart is on (the default). This step ran even when the process was not interactive,
    and it ignored both HACK_CANDIDATE_ENTRYPOINT and HACK_DAEMON_AUTOSTART_DISABLE. It is
    reachable from hack-next global up, from global install, and from the interactive
    hack-next up prompt that offers to start global services (maybePromptToStartGlobal).

  3. On macOS, hack global install offers to run hack daemon install
    (maybeOfferMacHackdLaunchdInstall), and a non-interactive run accepts the offer. That command
    also runs through resolveHackInvocation, so hack-next global install installed the stable
    hackd launchd service and started the stable hackd through it. This check also ignored both
    variables.

  4. hack doctor --fix repairs hackd (maybeRepairHackd): it restarts an incompatible hackd, clears
    stale pid/socket files and starts hackd, each through hack daemon ... on the first hack on
    PATH. A non-interactive run accepts every step. So hack-v5 doctor --fix --no-interactive with
    a stale daemon pid ran the stable hack daemon clear and hack daemon start without asking.
    This also ignored both variables, and the earlier docs wrongly said it would ask.

On the Compose backend that stable hackd then runs beside the candidate on the same ~/.hack.
scripts/hack-v5.sh set no guard.

Fix

  • src/daemon/client.ts: new resolveDaemonAutostartAccess. It denies autostart when
    HACK_DAEMON_AUTOSTART_DISABLE=1 (new, documented opt-out) or when HACK_CANDIDATE_ENTRYPOINT is
    set to any non-empty value. hack-next already sets that marker to hack-next, and the bundled
    launcher sets it to hack-v5. ensureDaemonReady checks it first, so a denied run reads no config
    and touches neither launchd nor PATH. Callers already fall back to direct Docker inspection, as for
    a non-interactive run. An explicit hack daemon start still works.
  • src/commands/global.ts (rework): the daemon.autoStart step of global up moved into
    ensureHackdForGlobalUp. It checks resolveDaemonAutostartAccess first. When the run is denied,
    it logs Skipping hackd start: <reason>, reads no config and spawns nothing. Caddy and logging
    still start. The global install and hack up prompt paths go through globalUp, so the same
    check covers that step there.
  • src/commands/global.ts (second rework, 5ddf03ee): maybeOfferMacHackdLaunchdInstall checks
    resolveDaemonAutostartAccess first. A denied run logs Skipping hackd launchd setup: <reason>
    before it reads launchd state or prompts, and it runs no hack daemon install. The function is
    exported for tests.
  • src/commands/doctor.ts (third rework, ea48b508): maybeRepairHackd checks
    resolveDaemonAutostartAccess after it reads the daemon status and before it prompts or runs
    anything, in interactive and non-interactive mode alike. A denied run skips the restart, clear
    and start steps and prints one doctor fix note: Skipped hackd repair (hackd is <status>): <reason> and To repair it yourself, run: <command>. The command is hack daemon restart for an
    incompatible hackd, hack daemon clear && hack daemon start for a stale one, and
    hack daemon start otherwise. An allowed run prompts and repairs as before. The
    DAEMON_AUTOSTART_DISABLE_ENV doc now lists this path as covered.
  • scripts/hack-v5.sh: exports HACK_DAEMON_AUTOSTART_DISABLE=1 for both backends. An inherited
    HACK_DAEMON_AUTOSTART_DISABLE=0 cannot turn autostart back on.
  • docs/guides/candidate-install.md: run stable hack doctor or hack doctor --fix before
    hack daemon stop, because v4.2.0 doctor --fix (maybeRepairHackd) offers to start hackd.
    When pgrep -fl "hack daemon start" still lists a process, run stable hack daemon uninstall
    (or launchctl bootout "gui/$(id -u)/dance.hack.hackd"), then hack daemon stop again, and run
    hack daemon install afterwards to restore the service. Third rework: candidate-install.md,
    native-candidate.md and architecture.md say that newer candidate builds do not start the
    stable hackd on their own, including from a candidate global up, global install or
    doctor --fix; that a candidate doctor --fix skips its hackd repair without asking, in a
    terminal or not, and prints the reason and the command to run; that this command runs the stable
    CLI; and that older bundles still can start hackd. They also correct the stable v4.2.0 behaviour:
    stable doctor --fix asks before it starts hackd only in a terminal and starts it without asking
    in a non-interactive run, so the guide says not to run it again after hack daemon stop.
    native-candidate.md and v5-dogfooding.md follow the same order. docs/architecture.md
    documents the opt-out and every skipped step.
  • src/backends/native-authored-project-start.ts: the HACK-1272 comment named
    native_graph_admission for a non-content-ID image as a refusal before reservation. fix(native): refuse non-content-ID images at native plan and native validate #372
    (HACK-1300) makes plan refuse that image first. The comment now names reachable examples: a
    native_graph_canceled cancel, a source or review that changed after planning, and an
    unreachable engine.
  • src/lib/native-compose-ownership.ts: the waitNativeComposeStartupOwned doc now lists both
    shapes that trigger the settled check. The second one, which the old doc left out, is a
    running or restarting member whose ep- placeholder first appears in the recheck scan
    (transient also counts recheck placeholders).

The branch also merges origin/next three times: at 616407a5, at 4e0bfa55 in the second
rework, and at 2da56da0 in the third rework (merge commit 3e507ab8). No merge had conflicts.

Tests

tests/launchd-isolation.test.ts uses its existing isolation: darwin preload, a private HOME and
HACK_HOME, and a stub launchctl plus a stub hack first on PATH. The driver exits if the stub is
not first. Nothing reaches the real launchd, HOME or the Docker socket.

  • A table test for resolveDaemonAutostartAccess: empty or blank marker, 0/true values, trimmed
    1, and the precedence between the marker and the opt-out.
  • The new autostart mode of tests/fixtures/launchd-isolation-driver.ts reports TTYs, so the
    interactive gate is open, and makes one default requestDaemonJson call. Six cases:
    • Denials: hack-next with the default home, hack-next with an isolated home, the hack-v5
      launcher env, and the opt-out alone. Each must record no launchctl call, no hack call, no
      socket file, and an unchanged plist.
    • Positive controls: a stable run with the default home records launchctl print and
      kickstart. A stable run with an isolated home records hack daemon start.
    • Third rework: during the request the driver wraps Bun.file, Bun.which, Bun.spawn and
      Bun.spawnSync and prints what they saw. Each denial case must show no read of the global
      config path, no PATH lookup and no spawn. Both positive controls must show the config read and
      the launchctl or hack spawn, so the recorder is proven to see them.
  • tests/doctor-fix-noninteractive.test.ts (third rework): the existing harness (mocked shell
    run/exec, temp HOME, isMac true, a stale daemon pid) runs
    doctor --fix --no-interactive four times: the hack-v5 env (HACK_CANDIDATE_ENTRYPOINT=hack-v5
    plus HACK_DAEMON_AUTOSTART_DISABLE=1, as scripts/hack-v5.sh sets them),
    HACK_CANDIDATE_ENTRYPOINT=hack-next, the opt-out alone, and a positive control with neither.
    Each denial must record no daemon command and print the skip note with (hackd is stale), the
    reason, and To repair it yourself, run: hack daemon clear && hack daemon start. The positive
    control must record exactly daemon clear then daemon start and no skip note.
  • tests/native-candidate-launcher.test.ts asserts that the launcher exports the opt-out on both
    backends, even when the caller passes HACK_DAEMON_AUTOSTART_DISABLE=0.
  • tests/global-command.test.ts (rework): global up under HACK_CANDIDATE_ENTRYPOINT=hack-v5
    and under HACK_DAEMON_AUTOSTART_DISABLE=1. It uses the file's existing shell mock and a temp
    HOME. It asserts no daemon command, exactly one skip line that names the reason, no
    "Ensuring hackd is running" line, and that Caddy and logging still come up. The existing positive
    test still asserts that a stable global up runs hack daemon start. beforeEach clears both
    variables, and afterEach restores them.
  • tests/global-command.test.ts (second rework): the macOS global install launchd offer is run
    directly and non-interactively (HACK_NO_INTERACTIVE=1, so the offer is accepted). HOME is the
    temp dir, so no plist exists and no launchctl runs, and run is mocked. The positive control
    records exactly one hack daemon install call. Under HACK_CANDIDATE_ENTRYPOINT=hack-next and
    under HACK_DAEMON_AUTOSTART_DISABLE=1, no daemon command is recorded and exactly one skip line
    names the reason.

Mutation proof (each mutation reverted afterwards):

Mutation Failing tests
Remove the guard in ensureDaemonReady the 4 denial cases
Drop the candidate-marker check the table test + both hack-next cases
Drop the opt-out check the table test + the opt-out case
Remove the export from hack-v5.sh all 4 launcher tests
Remove the guard in ensureHackdForGlobalUp both new global up cases (6 pass, 2 fail)
Remove the guard in maybeOfferMacHackdLaunchdInstall (5ddf03ee) both global install denial cases (9 pass, 2 fail)
Always skip the global install launchd offer (5ddf03ee) the global install positive control (10 pass, 1 fail)
Remove the guard in ensureHackdForGlobalUp, rerun at 5ddf03ee both global up denial cases (9 pass, 2 fail)
Disable the guard in maybeRepairHackd (ea48b508) the 3 doctor --fix denial cases (11 pass, 3 fail); each records daemon clear and daemon start, the reported defect
Move the ensureDaemonReady guard after canPrompt() and readControlPlaneConfig (ea48b508) the 4 requestDaemonJson denial cases (10 pass, 4 fail), on the config-read assertion

End-to-end probe (rework): bun index.ts global up with a private HOME and HACK_HOME, stub
docker, hack, launchctl, brew and sudo first on a minimal PATH, and the platform preloaded
as linux. The env was HACK_CANDIDATE_ENTRYPOINT=hack-v5 HACK_DAEMON_AUTOSTART_DISABLE=1 HACK_RUNTIME_BACKEND=compose HACK_NO_INTERACTIVE=1. In the review run at b21ec543, the stub
hack recorded daemon start. With the fix, the stub hack recorded nothing and the output shows
Skipping hackd start: hackd autostart is disabled by HACK_DAEMON_AUTOSTART_DISABLE=1.

Evidence

  • Bun 1.4.2 (mise x bun@1.4.2).
  • Local (this Mac), at ea48b508 (the earlier head merged with origin/next 2da56da0, plus the
    doctor --fix fix): bun run --cwd packages/cli typecheck exited 0. Root bun run check
    exited 0 with privacy-check: ok (its 34 Biome warnings are existing complexity warnings in
    other files). Biome on the 5 TS files changed in this rework is clean.
  • Focused tests at ea48b508, run with HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1:
    doctor-fix-noninteractive, launchd-isolation, daemon-autostart-bounded, daemon-client,
    global-command, native-candidate-launcher and doctor-fix-adopted-project: 54 pass, 0 fail.
  • No test reached the real launchd, HOME, Docker socket or keychain: launchd-isolation uses its
    stub launchctl/hack and private HOME, and the doctor --fix cases use the mocked shell and a
    temp HOME.
  • gitleaks 8.30.1 (gitleaks detect --log-opts=origin/next..HEAD): 4 commits scanned, no leaks.
  • Earlier reworks: hosted CI run 39877694201 on 5ddf03ee passed all 16 checks. A verifier
    confirmed brief items 1-5 for requestDaemonJson, global up and global install, and found the
    doctor --fix defect fixed here.
  • Hosted CI run 39882998921 on ea48b508: all 16 checks passed, including test (the full suite), docker-e2e shards 1-4, Candidate core (macOS and Ubuntu), Config compiler x4, Runtime state models and secret-scan.

Gaps

  • No local full suite. Hosted CI test is the full-suite signal.
  • The global install launchd offer is tested by calling the function directly, not through
    runCli(["global", "install"]) with isMac mocked true. The macOS install bootstrap around it
    (DNS, trust, sudoers) is not exercised.
  • The interactive doctor --fix path has no test of its own. The guard runs before the first
    prompt and does not depend on the mode, and the non-interactive tests prove it.
  • No real-launchd or installed-bundle check. The guards are proven against the stub launchctl,
    the stub hack and the mocked shell only. No hack-next bundle was built or installed.
  • Older candidate bundles do not have these guards. The guide still requires stopping stable hackd
    for every bundle.
  • Moving the ensureDaemonReady guard after canPrompt() alone is not detected. canPrompt()
    only reads TTY and env state, so that order has no observable effect.
  • The gateway and supervisor daemon start calls are explicit, unsupported flows and are unchanged.

Refs HACK-1368

🤖 Generated with Claude Code

A candidate command (`hack-next`, or the bundled `hack-v5` entrypoint) that
asked hackd for data in an interactive TTY autostarted hackd. Autostart starts
what the host resolves: the per-user launchd plist (the stable binary) or the
first `hack` on PATH (the stable CLI). On the Compose backend that put a stable
hackd beside the candidate on the same ~/.hack.

- requestDaemonJson autostart now refuses first, before reading config or
  touching launchd or PATH, when HACK_CANDIDATE_ENTRYPOINT is set or the new
  documented opt-out HACK_DAEMON_AUTOSTART_DISABLE=1 is set.
- scripts/hack-v5.sh exports HACK_DAEMON_AUTOSTART_DISABLE=1.
- Candidate guide: run stable `hack doctor`/`--fix` before `hack daemon stop`,
  and give the launchd remedy when `pgrep` still lists a hackd.
- Comment fixes: name reachable pre-reservation refusals, and describe both
  shapes that trigger the settled startup check.

Refs HACK-1368

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Oct 22, 2026

Copy link
Copy Markdown

HACK-1368

roodboi and others added 7 commits October 22, 2026 11:47
`hack global up` ran `hack daemon start` through the first `hack` on PATH
whenever `daemon.autoStart` was on, even under HACK_CANDIDATE_ENTRYPOINT or
HACK_DAEMON_AUTOSTART_DISABLE=1. For a candidate run that `hack` is the stable
CLI, so `hack-next global up`, `global install` and the `hack-next up` prompt
started the stable hackd on the shared ~/.hack (HACK-1368).

The step now checks resolveDaemonAutostartAccess() first, logs the skip, and
reads no config when denied. Tests cover both variables; removing the guard
fails both.

Refs HACK-1368

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

On macOS, `hack global install` offers to run `hack daemon install` and
non-interactive runs accept it. That runs through the first `hack` on PATH,
which for a candidate run is the stable CLI, so `hack-next global install`
installed the stable hackd launchd service and started the stable hackd on
the shared ~/.hack, even under HACK_DAEMON_AUTOSTART_DISABLE=1 (HACK-1368).

The offer now checks resolveDaemonAutostartAccess() first and logs the skip
before it reads launchd state or prompts. Tests cover both variables and a
positive control; removing the guard fails both denial cases, and always
skipping fails the control.

The docs no longer say the candidate never starts the stable hackd: the
interactive `hack doctor --fix` start and restart prompts still run the stable
CLI, so the guides say to answer no to them.

Refs HACK-1368

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A candidate `hack doctor --fix --no-interactive` (hack-next, or hack-v5 with
HACK_DAEMON_AUTOSTART_DISABLE=1) with a stale daemon pid still ran
`hack daemon clear` and `hack daemon start` through the first `hack` on PATH,
the stable CLI, without asking. The docs said it would ask.

- maybeRepairHackd now checks resolveDaemonAutostartAccess() before it
  prompts or runs anything, in interactive and non-interactive mode alike.
  A denied run skips the restart, clear and start steps and notes the reason
  and the command to run (for example `hack daemon clear && hack daemon start`).
- The launchd isolation driver records Bun.file, Bun.which and Bun.spawn
  calls during a denied daemon request. The test asserts no config read, no
  PATH lookup and no spawn, so the autostart guard must stay first in
  ensureDaemonReady.
- architecture.md, candidate-install.md and native-candidate.md now describe
  the skip, and that stable `doctor --fix` starts hackd without asking when
  non-interactive.

Refs HACK-1368

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve tests/doctor-fix-noninteractive.test.ts: both sides appended tests
at the file end. Keep this branch's HACK-1368 hackd-repair cases and next's
HACK-1316 symlinked-.hack test (#432) in order; 15/15 pass together.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@roodboi
roodboi marked this pull request as ready for review October 23, 2026 05:00
@roodboi
roodboi merged commit a74eedb into next Oct 23, 2026
16 checks passed
@roodboi
roodboi deleted the wf/g49-candidate-no-stable-hackd branch October 23, 2026 05:00
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