Repository navigation
fix(daemon): never autostart the stable hackd from a candidate run - #416
Merged
Merged
Conversation
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>
`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>
…table-hackd-c75-5-local
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root cause
A candidate run could start the stable hackd in four ways. Each runs whatever hackd the host
resolves, not the running CLI.
requestDaemonJsonautostarts hackd by default when it runs in an interactive TTY:stable binary;
startDaemonViaCli, whereresolveHackInvocationpicks the firsthackonPATH, which is the stable CLI.
So a candidate command such as
HACK_RUNTIME_BACKEND=compose hack-next ps --json(the daemonquery in
src/commands/project.ts) could start the stable hackd.hack global uprunshack daemon startthroughresolveHackInvocationwheneverdaemon.autoStartis on (the default). This step ran even when the process was not interactive,and it ignored both
HACK_CANDIDATE_ENTRYPOINTandHACK_DAEMON_AUTOSTART_DISABLE. It isreachable from
hack-next global up, fromglobal install, and from the interactivehack-next upprompt that offers to start global services (maybePromptToStartGlobal).On macOS,
hack global installoffers to runhack daemon install(
maybeOfferMacHackdLaunchdInstall), and a non-interactive run accepts the offer. That commandalso runs through
resolveHackInvocation, sohack-next global installinstalled the stablehackd launchd service and started the stable hackd through it. This check also ignored both
variables.
hack doctor --fixrepairs hackd (maybeRepairHackd): it restarts an incompatible hackd, clearsstale pid/socket files and starts hackd, each through
hack daemon ...on the firsthackonPATH. A non-interactive run accepts every step. So
hack-v5 doctor --fix --no-interactivewitha stale daemon pid ran the stable
hack daemon clearandhack daemon startwithout 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.shset no guard.Fix
src/daemon/client.ts: newresolveDaemonAutostartAccess. It denies autostart whenHACK_DAEMON_AUTOSTART_DISABLE=1(new, documented opt-out) or whenHACK_CANDIDATE_ENTRYPOINTisset to any non-empty value.
hack-nextalready sets that marker tohack-next, and the bundledlauncher sets it to
hack-v5.ensureDaemonReadychecks it first, so a denied run reads no configand touches neither launchd nor PATH. Callers already fall back to direct Docker inspection, as for
a non-interactive run. An explicit
hack daemon startstill works.src/commands/global.ts(rework): thedaemon.autoStartstep ofglobal upmoved intoensureHackdForGlobalUp. It checksresolveDaemonAutostartAccessfirst. When the run is denied,it logs
Skipping hackd start: <reason>, reads no config and spawns nothing. Caddy and loggingstill start. The
global installandhack upprompt paths go throughglobalUp, so the samecheck covers that step there.
src/commands/global.ts(second rework,5ddf03ee):maybeOfferMacHackdLaunchdInstallchecksresolveDaemonAutostartAccessfirst. A denied run logsSkipping hackd launchd setup: <reason>before it reads launchd state or prompts, and it runs no
hack daemon install. The function isexported for tests.
src/commands/doctor.ts(third rework,ea48b508):maybeRepairHackdchecksresolveDaemonAutostartAccessafter it reads the daemon status and before it prompts or runsanything, in interactive and non-interactive mode alike. A denied run skips the restart, clear
and start steps and prints one
doctor fixnote:Skipped hackd repair (hackd is <status>): <reason>andTo repair it yourself, run: <command>. The command ishack daemon restartfor anincompatible hackd,
hack daemon clear && hack daemon startfor a stale one, andhack daemon startotherwise. An allowed run prompts and repairs as before. TheDAEMON_AUTOSTART_DISABLE_ENVdoc now lists this path as covered.scripts/hack-v5.sh: exportsHACK_DAEMON_AUTOSTART_DISABLE=1for both backends. An inheritedHACK_DAEMON_AUTOSTART_DISABLE=0cannot turn autostart back on.docs/guides/candidate-install.md: run stablehack doctororhack doctor --fixbeforehack daemon stop, because v4.2.0doctor --fix(maybeRepairHackd) offers to start hackd.When
pgrep -fl "hack daemon start"still lists a process, run stablehack daemon uninstall(or
launchctl bootout "gui/$(id -u)/dance.hack.hackd"), thenhack daemon stopagain, and runhack daemon installafterwards to restore the service. Third rework:candidate-install.md,native-candidate.mdandarchitecture.mdsay that newer candidate builds do not start thestable hackd on their own, including from a candidate
global up,global installordoctor --fix; that a candidatedoctor --fixskips its hackd repair without asking, in aterminal 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 --fixasks before it starts hackd only in a terminal and starts it without askingin a non-interactive run, so the guide says not to run it again after
hack daemon stop.native-candidate.mdandv5-dogfooding.mdfollow the same order.docs/architecture.mddocuments the opt-out and every skipped step.
src/backends/native-authored-project-start.ts: the HACK-1272 comment namednative_graph_admissionfor 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_canceledcancel, a source or review that changed after planning, and anunreachable engine.
src/lib/native-compose-ownership.ts: thewaitNativeComposeStartupOwneddoc now lists bothshapes that trigger the
settledcheck. The second one, which the old doc left out, is arunningorrestartingmember whoseep-placeholder first appears in the recheck scan(
transientalso counts recheck placeholders).The branch also merges
origin/nextthree times: at616407a5, at4e0bfa55in the secondrework, and at
2da56da0in the third rework (merge commit3e507ab8). No merge had conflicts.Tests
tests/launchd-isolation.test.tsuses its existing isolation: darwin preload, a private HOME andHACK_HOME, and a stub
launchctlplus a stubhackfirst on PATH. The driver exits if the stub isnot first. Nothing reaches the real launchd, HOME or the Docker socket.
resolveDaemonAutostartAccess: empty or blank marker,0/truevalues, trimmed1, and the precedence between the marker and the opt-out.autostartmode oftests/fixtures/launchd-isolation-driver.tsreports TTYs, so theinteractive gate is open, and makes one default
requestDaemonJsoncall. Six cases:hack-nextwith the default home,hack-nextwith an isolated home, thehack-v5launcher env, and the opt-out alone. Each must record no
launchctlcall, nohackcall, nosocket file, and an unchanged plist.
launchctl printandkickstart. A stable run with an isolated home recordshack daemon start.Bun.file,Bun.which,Bun.spawnandBun.spawnSyncand prints what they saw. Each denial case must show no read of the globalconfig path, no PATH lookup and no spawn. Both positive controls must show the config read and
the
launchctlorhackspawn, so the recorder is proven to see them.tests/doctor-fix-noninteractive.test.ts(third rework): the existing harness (mocked shellrun/exec, temp HOME,isMactrue, a stale daemon pid) runsdoctor --fix --no-interactivefour times: thehack-v5env (HACK_CANDIDATE_ENTRYPOINT=hack-v5plus
HACK_DAEMON_AUTOSTART_DISABLE=1, asscripts/hack-v5.shsets them),HACK_CANDIDATE_ENTRYPOINT=hack-next, the opt-out alone, and a positive control with neither.Each denial must record no
daemoncommand and print the skip note with(hackd is stale), thereason, and
To repair it yourself, run: hack daemon clear && hack daemon start. The positivecontrol must record exactly
daemon clearthendaemon startand no skip note.tests/native-candidate-launcher.test.tsasserts that the launcher exports the opt-out on bothbackends, even when the caller passes
HACK_DAEMON_AUTOSTART_DISABLE=0.tests/global-command.test.ts(rework):global upunderHACK_CANDIDATE_ENTRYPOINT=hack-v5and under
HACK_DAEMON_AUTOSTART_DISABLE=1. It uses the file's existing shell mock and a tempHOME. It asserts no
daemoncommand, 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 uprunshack daemon start.beforeEachclears bothvariables, and
afterEachrestores them.tests/global-command.test.ts(second rework): the macOSglobal installlaunchd offer is rundirectly and non-interactively (
HACK_NO_INTERACTIVE=1, so the offer is accepted). HOME is thetemp dir, so no plist exists and no
launchctlruns, andrunis mocked. The positive controlrecords exactly one
hack daemon installcall. UnderHACK_CANDIDATE_ENTRYPOINT=hack-nextandunder
HACK_DAEMON_AUTOSTART_DISABLE=1, nodaemoncommand is recorded and exactly one skip linenames the reason.
Mutation proof (each mutation reverted afterwards):
ensureDaemonReadyhack-nextcaseshack-v5.shensureHackdForGlobalUpglobal upcases (6 pass, 2 fail)maybeOfferMacHackdLaunchdInstall(5ddf03ee)global installdenial cases (9 pass, 2 fail)global installlaunchd offer (5ddf03ee)global installpositive control (10 pass, 1 fail)ensureHackdForGlobalUp, rerun at5ddf03eeglobal updenial cases (9 pass, 2 fail)maybeRepairHackd(ea48b508)doctor --fixdenial cases (11 pass, 3 fail); each recordsdaemon clearanddaemon start, the reported defectensureDaemonReadyguard aftercanPrompt()andreadControlPlaneConfig(ea48b508)requestDaemonJsondenial cases (10 pass, 4 fail), on the config-read assertionEnd-to-end probe (rework):
bun index.ts global upwith a private HOME and HACK_HOME, stubdocker,hack,launchctl,brewandsudofirst on a minimal PATH, and the platform preloadedas 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 atb21ec543, the stubhackrecordeddaemon start. With the fix, the stubhackrecorded nothing and the output showsSkipping hackd start: hackd autostart is disabled by HACK_DAEMON_AUTOSTART_DISABLE=1.Evidence
mise x bun@1.4.2).ea48b508(the earlier head merged withorigin/next2da56da0, plus thedoctor --fixfix):bun run --cwd packages/cli typecheckexited 0. Rootbun run checkexited 0 with
privacy-check: ok(its 34 Biome warnings are existing complexity warnings inother files). Biome on the 5 TS files changed in this rework is clean.
ea48b508, run withHACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1:doctor-fix-noninteractive,launchd-isolation,daemon-autostart-bounded,daemon-client,global-command,native-candidate-launcheranddoctor-fix-adopted-project: 54 pass, 0 fail.launchd-isolationuses itsstub
launchctl/hackand private HOME, and thedoctor --fixcases use the mocked shell and atemp HOME.
gitleaks detect --log-opts=origin/next..HEAD): 4 commits scanned, no leaks.5ddf03eepassed all 16 checks. A verifierconfirmed brief items 1-5 for
requestDaemonJson,global upandglobal install, and found thedoctor --fixdefect fixed here.ea48b508: all 16 checks passed, includingtest(the full suite),docker-e2eshards 1-4, Candidate core (macOS and Ubuntu), Config compiler x4, Runtime state models andsecret-scan.Gaps
testis the full-suite signal.global installlaunchd offer is tested by calling the function directly, not throughrunCli(["global", "install"])withisMacmocked true. The macOS install bootstrap around it(DNS, trust, sudoers) is not exercised.
doctor --fixpath has no test of its own. The guard runs before the firstprompt and does not depend on the mode, and the non-interactive tests prove it.
launchctl,the stub
hackand the mocked shell only. Nohack-nextbundle was built or installed.for every bundle.
ensureDaemonReadyguard aftercanPrompt()alone is not detected.canPrompt()only reads TTY and env state, so that order has no observable effect.
daemon startcalls are explicit, unsupported flows and are unchanged.Refs HACK-1368
🤖 Generated with Claude Code