Skip to content

fix(cli): pass child flags through and print closed native refusals - #359

Merged
roodboi merged 7 commits into
nextfrom
wf/g1-cli-refusals-b17
Oct 21, 2026
Merged

roodboi merged 7 commits into
nextfrom
wf/g1-cli-refusals-b17

Conversation

@roodboi

@roodboi roodboi commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This fixes four CLI parsing and refusal problems seen on the M3 with v5.0.0-next.5, plus the up --detach message. The branch is rebased onto current next (54cc4c9), which includes #331.

1. Child flags no longer reach Hack's parser

Before: hack run web --profile toolchain bun --version printed Hack's version and exited 0.

Now a positional can be marked passthrough. From the child command's first word on, every token belongs to the child. That includes --version, --help, --json, --path, --branch and any other flag.

  • A leading -- is still optional and is not passed on.
  • A later --, as in hack run web npm test -- --watch, now reaches the child unchanged. Before, it was dropped.
  • Commands marked: run, exec, host exec, env exec, session exec and x.
  • Hack options still work before the child command.
  • findPassthroughStart mirrors the parser, including options with default values.
  • A child --json no longer selects Hack's JSON error envelope. Only argv before the child command and before the first -- counts (hackOwnedArgv). So hack run --profile toolchain web tool --json and hack exec web cat --json print Hack's refusal as one human-readable line. hack --json run web tool --json still prints the envelope.
  • session exec takes its workspace command as one argument. hack session exec ws npm --version now refuses with Unexpected arguments: --version. <command> is one argument; quote the whole command, as in "npm --version". It does not print Hack's version.
  • Help shows hack run [options] <service> [cmd...] plus a one-line note. For session exec the note says to quote the command.
  • docs/reference/cli.md is regenerated, and docs/cli.md describes both rules.

MCP buildRunArgs/buildExecArgs already put -- before the service, so their behavior does not change.

2. Native refusals print one closed line, not a stack

  • The configuration refusal ("…require explicit native support…") is now HackCliError with E_NATIVE_PROJECT_UNSUPPORTED.
  • The HACK_NATIVE_SHARED_SOURCE requirement on up and restart is now E_PERMISSION, and the message says to set it and run again.
  • Malformed native HTTPS selections are now E_CONFIG_INVALID.
  • Native lifecycle preparation, readiness-hook and restart-cleanup failures are now E_LIFECYCLE_FAILED.
  • Native project input refusals are now E_NATIVE_PROJECT_UNSUPPORTED. This covers a service env_file, or a project .env without managed env. Unreviewed route labels are also E_NATIVE_PROJECT_UNSUPPORTED.
  • Branch-host, project-adaptation, AWS-adaptation and fresh-exec-environment refusals are now E_CONFIG_INVALID.
  • Other value-free native refusals are now ClosedCliError: no stack, unless HACK_DEBUG=1. This covers:
    • image-project start (pending restart, retained run changes, a missing relay artifact, HTTPS verification and the HTTPS owner, unconfirmed exit cleanup, source admission)
    • restart preflight
    • one-off run, exec, down, restart and dependency refresh
    • NativePartialShutdownError, NativeHttpsStartupError and NativeHttpsLeaseAcquisitionError
    • the invalid HACK_NATIVE_BINARY/HACK_NATIVE_HOME selection
  • Cancelling a native foreground start no longer reports the configuration-refusal text.

3. A refused run no longer prints the full help

When a handler throws a CliUsageError after parsing has succeeded, Hack now prints the message plus one line: See `hack run --help` for usage. Usage errors found while parsing still print the help. Under --json the output is still one E_USAGE envelope.

4. Authored up with the runtime stopped

  • Status check. After native review and before reservation, an authored start makes one read-only runtime status --json request. A stopped runtime (phase not running, or a dead process) refuses at stage reservation with runtime_not_running. Nothing is retained, so ps stays not_started. An inconclusive reply does not refuse.
  • Cancel during the check. The check treats a failed request as inconclusive, including one that a cancel ended. So the start checks its deadline and cancellation again after the check, before admission.reserve. A Ctrl-C during the check now ends not-started and ps stays not_started. Before this fix it ended retained and ps showed pending.
  • Error report. The CLI reports a stopped-runtime start as E_DOCKER_UNAVAILABLE (as logs already does), with a message for each outcome and the next action. Before, it reported E_STARTUP_INCOMPLETE. This now also covers fix(runtime): preserve authored startup and stopped-runtime causes #331's wrapped form: authored foreground control reports native_graph_foreground with cause runtime_not_running. The serve exit diagnostic now carries that bounded cause code to NativeAuthoredProjectStartError.nativeCauseCode. nativeCodesReportRuntimeNotRunning classifies both forms, as isNativeRuntimeNotRunning does.
  • Why pending can still be correct. The ps pending note and docs/reference/native-authored-commands.md explain the remaining case. When the intent was reserved before the runtime stopped, pending is still correct, because only the owner's cleanup proof may retire that intent.

up --detach on native

  • Image-project up with --detach, --json, --target or service names, and restart with --json, --target or service names, now refuse with a one-line E_USAGE message. (restart has no --detach option, so hack restart --detach is still an argument parse error that prints the help, as before.) It names the option and the supported form: run hack up in the foreground in its own terminal, use hack ps and hack logs from another, and press Ctrl-C to stop it.
  • Authored up --detach keeps E_NATIVE_PROJECT_UNSUPPORTED and now adds the same guidance.

Validation

At head c5b4202, based on next 54cc4c9:

  • bun run --cwd packages/cli typecheck: clean. bun run --cwd packages/cli check: 0 errors and 34 warnings, the same count as on next. Changed files lint clean with Biome.
  • docs:cli-reference is regenerated and is stable on a rerun.
  • Required: tests/integration-ownership.test.ts and tests/cli-spawn-env-isolation.test.ts pass.
  • These 29 files pass together, 486 tests and 0 failures: integration-ownership, cli-spawn-env-isolation, cli-reference-docs, cli-help, cli-closed-error-output, cli-passthrough-argv, cli-command, native-runtime-not-running-cli, native-runtime-preflight, native-authored-command, native-project-observe, native-project-start, native-project-restart, native-runtime-client, native-exec-environment, native-project-input, native-project-routing, native-project-branch, native-project-adaptation, native-aws-environment, native-project-source-preflight, native-exec-stale-env, native-project-process, native-authored-project-process, native-authored-project-restart, native-authored-command-dispatch, mcp, native-project-branch-cli and native-project-down.
  • A broader run of about 45 more CLI and native files that import the changed modules passed, apart from the two failures below.
  • tests/native-authored-project-start.test.ts: 48/48 in one run. Under host load (load average 11–13), another run cascaded through the fixture-settlement guard. The first failing test passed on its own. This is the known flakiness under load, which also happens on unchanged next.

New spawn tests use tests/helpers/private-cli-env.ts, with a private HOME and an isolated Docker client config. No real Docker, HOME or keychain is used.

Mutation proofs. Each fix was reverted, the listed tests failed, and the fix was restored.

Mutation Failing tests
Passthrough boundary disabled in parseCliArgv 47 of 51 in cli-passthrough-argv
runCli reads --json from raw argv again 3 child --json spawn tests
remaining() after the status check removed "cancel during the runtime status check refuses before the startup intent"
Handler usage error prints the full help again handler-hint test, and the spawned native run --profile refusal test
Configuration refusal back to plain Error 3 coded-configuration-refusal cases
Cancel throws the configuration refusal again "a canceled start reports cancellation, not the configuration refusal"
Shared-source refusal back to plain Error "source opt-in and occupied mapping refuse…"
Input, routing, branch, adaptation, AWS, fresh-exec (2 sites), source-admission, restart-preflight and runtime-selection refusals each back to plain Error the coded/closed assertion in each module's test file
Native up --detach refusal removed native-project-observe refusal spawn test, and the up --detach closed-line test
Authored start status check removed "a stopped runtime refuses before the startup intent…"
nativeRuntimeStatusStopped check removed 4 preflight status-reply cases, and the authored start test
Stopped-runtime start mapping removed 2 E_DOCKER_UNAVAILABLE mapping cases
Wrapped-cause mapping reverted to nativeCode === runtime_not_running the native_graph_foreground mapping case
Exit diagnostic drops nativeCauseCode "a runtime that stops during serve keeps the wrapped stopped-runtime cause"
Wrapped branch of nativeCodesReportRuntimeNotRunning removed the carried code-pair test
session exec quoting hint removed the parse test and the spawned session exec test
Text-help passthrough note removed, or single-argument note variant removed cli-help "text help says where a child command's own argv begins"
Authored detach hint removed "a detached native up names the foreground-only contract…"
NativePartialShutdownError back to Error closed-class output test
Dependency-refresh refusal back to Error "a child --version reaches the native one-off…" (stack printed)
Passthrough usage rendering removed cli-reference-docs drift test

Gaps

  • There was no live native VM run. Fake runtime binaries prove the stopped-runtime paths. One answers runtime status the way the Rust status() does for a stopped pool. Another fails serve with native_graph_foreground/runtime_not_running. The status check is advisory: if the runtime stops between the check and serve, the retained-evidence path still applies, and the CLI reports it as E_DOCKER_UNAVAILABLE with the pending explanation.
  • The wrapped cause also reaches the start error when an authored runtime request (rather than the serve process) fails with it. No test covers that request path on its own.
  • tests/native-authored-project-recovery.test.ts has 2 failures ("new no-host death…" and "new no-host recovery retains original bindings…"). They fail the same way on unchanged next on this host.
  • An unquoted session exec command is still a parse-time usage error, so it prints the root help after the quoting hint.
  • dispatch run (Beta, unsupported) was left unchanged.
  • Some other native modules (native-authored-* internals, compose adoption, DNS) still throw plain Error. This PR covers the image-project start, run, exec, down, restart (including its preflight) and dependency-refresh paths, their input/routing/branch/adaptation/AWS steps, and the authored start refusals.

Rework 2

This round fixes the independent verifier's defects at c5b4202.

Refusal classes

Every known, user-reachable refusal on the image-project up, restart, run, exec and ps paths now prints one closed line. The class follows what was refused:

  • Configuration is HackCliError with a stable code:
    • E_CONFIG_INVALID: an unbounded --profile, a malformed HACK_NATIVE_DEPENDENCIES listener selection or private selection file, HACK_NATIVE_ALLOW_HOSTS, a conflicting host-alias selection, an occupied HTTPS port, and a request over the runtime client's input or time budget (a large managed environment or a long startup timeout).
    • E_NATIVE_PROJECT_UNSUPPORTED: a service that sets init other than true with host dependencies, a Compose file outside the project, an active review with no supported service, and runtime metadata whose route labels do not match the reviewed plan.
    • E_PERMISSION: an HTTPS port that needs host permission.
  • Integrity, ownership or change is ClosedCliError. Without --json it prints the message alone. Under --json the code is E_UNEXPECTED, the existing code for a failure whose message is the only detail. This matches the down, exec, one-off and restart refusals from round 1. No existing code says "native state changed or could not be confirmed", and E_CONFIG_INVALID would wrongly tell an agent to fix its configuration. This covers:
    • a saved mapping that is unsafe or owned by another run, and the restart lock;
    • changed review, restore, retained-startup and retained-image selections;
    • invalid cache review and an unverified source publication;
    • HTTPS verification and ownership;
    • an invalid runtime control or exec completion response, and an over-budget output;
    • the finalization, frontend-recovery and cleanup-inspection refusals;
    • the foreground serve process (native-project-process.ts).

Files converted beyond the verifier's list, because they are reachable from the same paths: native-project-network.ts, native-project-host-aliases.ts, native-project-selection.ts, native-runtime-metadata.ts, native-project-finalization.ts, native-project-recovery.ts, native-project-cleanup-command.ts and native-project-process.ts.

The HTTPS port message no longer echoes the port number.

Defects

  1. Native refusal audit. Fixed for every site the verifier listed, plus the files above. The repro HACK_NATIVE_SHARED_SOURCE=1 hack up --profile <300 chars> now prints ERROR: E_CONFIG_INVALID: Native profiles require at most 64 bounded nonempty names. with no stack, and makes no runtime request.

    • New tests/native-refusal-cli.test.ts spawns the source CLI with privateCliEnv and a stub runtime. The stub answers scripted <word> <word> requests and otherwise reports a stopped runtime.
    • It asserts exactly one ERROR: line, no stack frame, no Error: and no help for these sites:
      • native-project-run (profiles, unsafe mapping)
      • native-runtime-client (unparseable reply)
      • native-project-dependencies
      • native-project-review
      • native-project-cache
      • native-project-start (dependency plan)
      • native-project-retained-startup
      • native-project-restore (through a retained up with an acknowledged finalization)
      • native-project-retained-images (through restart)
      • native-project-network
      • the serve process (whose failure the cleanup line reports)
    • A spawn cannot reach native-project-https.ts or native-https-port.ts without a live serve owner. Those refusals are raised in the foreground owner's readiness step, inside NativeHttpsStartupError, or in doctor checks that report a result instead. tests/cli-closed-error-output.test.ts renders their real errors through the top-level handler instead: one line, no stack.
  2. hack dispatch run child argv. Fixed. The command positional is passthrough: true. hack dispatch run --project p bun --version no longer prints Hack's version. Covered by the passthrough parse matrix (8 child-flag cases) and a source-CLI spawn case. docs/cli.md lists dispatch run. docs:cli-reference is unchanged, because Beta commands are not in the reference.

  3. Mutations that survived.

    • (a) Fixed. The preflight test has {phase: "initializing", process_alive: null}, so phase === "stopped" now fails it.
    • (b) Fixed. A new helper, tests/helpers/closed-refusal.ts (expectClosedRefusal and expectCodedRefusal, plus sync forms), asserts the class and code as well as the text. The restart-preflight (admission and the other closed restart refusals), down, exec, one-off, dependency-refresh and interrupted-cleanup tests now use it. A new test covers one-off argument validation.
  4. Integrity refusals in native-project-start.ts. Fixed. The shared configuration refused() is now used only for configuration. Each of these has its own closed message, and none says E_NATIVE_PROJECT_UNSUPPORTED or "cannot admit this configuration":

    • an ensure-image reply that is not a content ID, and an authoritative() receipt mismatch: "could not confirm the runtime image selection / the startup receipt";
    • an invalid dependency_plan_id: "could not confirm the host dependency plan";
    • a retireRemovedMapping owner mismatch: "Native run mapping is owned by another run; it was not removed";
    • a missing HTTPS verification response, or an unparseable redirect: Native HTTPS verification failed (NO_RESPONSE) or (REDIRECT_TARGET_REFUSED), in the same fixed form as the other verification failures, so the cleanup diagnostic keeps them.

    New tests in tests/native-project-start.test.ts cover each one.

  5. up --detach. Fixed. Both paths use nativeForegroundDetachRefusal. Authored up --detach on macOS now refuses with the image project's E_USAGE one-line message, which stands alone instead of trailing the generic sentence. Tests compare the authored refusal with the image refusal (code and full message) for up, and unit tests also pass detach to the authored restart handler directly. The CLI cannot reach that restart case: restart has no --detach option, so hack restart --detach is a parse error that prints the help (correction from verifier round 3). docs/reference/native-authored-commands.md is updated.

Also in scope: a new authored-start test covers the NativeRuntimeRequestError wrapped-cause branch in failedAdmission. The runtime status request fails with native_graph_foreground/runtime_not_running, and the start error keeps both codes at stage reservation.

Remaining plain Error sites (internal invariants)

  • native-runtime-client.ts: an argv shape that fails validExecResponseSelection/validBoundDrainSelection. Callers build these shapes, and no user input selects them. The user-reachable timeout and input bounds are now coded.
  • native-project-process.ts: "Native receipt observation must be synchronous". This is a programming contract on the injected observer.
  • native-project-exec.ts:137 is closed but cannot be reached in practice. Dependency refresh refuses an absent mapping first with its own closed "has not been started" message, so mutating this site survives. It is a defensive check for a removal between refresh and inspection.
  • Out of scope, as in round 1: native-authored-* internals and native-https-owner*. Authored start failures are wrapped by failedAdmission into coded CLI errors. HTTPS owner failures are wrapped by NativeHttpsStartupError, which is closed.

Validation (rework 2)

Head f8e823e, based on c5b4202. f8e823e fixes the six native plan CLI acceptance scripts (scripts/check-native-{env,host,routing,endpoint,process,acquisition}-plan-cli.ts), which run only in the macOS "Config compiler" CI job. On 6ec1ae2 that job failed: the scripts still expected E_NATIVE_PROJECT_UNSUPPORTED for authored up --detach. They now expect E_USAGE on macOS and keep E_NATIVE_PROJECT_UNSUPPORTED elsewhere. After bun run build, all six, check-native-config-import-cli and check-config-compiler-cli pass locally on Bun 1.4.2. All results come from Bun 1.4.2 (mise x bun@1.4.2 -- bun …) unless they say otherwise.

  • bun run --cwd packages/cli typecheck: clean. bun run --cwd packages/cli check: 0 errors and 34 warnings, the same count as next. Every changed file lints clean with Biome.
  • docs:cli-reference was rerun with no change. Beta dispatch is not in the reference.
  • gitleaks 8.30.1 over the 3 branch commits (next..HEAD): no leaks.
  • Full bun run test (1.4.2): 74 failures in a 1h18m run on a host with load average 13–32.
    • 1 was real: the authored-dispatch table still expected the generic up --detach refusal. It is now its own test that expects the shared E_USAGE line on macOS.
    • The other 15 failing files were rerun one at a time with --timeout 60000 and all passed, except native-authored-project-recovery "new no-host death…", the known HACK-1282 failure on next.
    • One 30 s source-bind adoption test also failed in its file rerun and then passed on its own with --timeout 120000.
  • Focused 1.4.2 run: the 31 changed test files plus cli-spawn-env-isolation, integration-ownership, cli-reference-docs, cli-help, native-runtime-not-running-cli and native-project-observe.
    • 608 pass. The 22 failures are the known fixture-settlement cascade in native-authored-project-start under load.
    • That file then passed 49/49 on its own on 1.4.2.
    • cli-spawn-env-isolation passed.
  • Earlier development runs and the mutation proofs below used Bun 1.4.3, the host default, before the 1.4.2 pin was required. The focused 1.4.2 run above repeats every test those mutations use.

Mutation proofs. Each was applied to the source, the listed tests failed, and the source was restored. Every mutation failed at least one test except exec.ts:137 (explained above).

Mutation Failing tests
Runtime client: invalid control response, exec completion, output budget, input/time budget back to Error (4 separate mutations) the spawn unparseable-reply case and the client tests: 2, 1, 1 and 1 tests
native-https-port.ts coded back to Error port test and handler render test
native-project-dependencies.ts coded back to Error 9 dependency, restart and spawn tests
native-project-run.ts mapping refused() / restart lock / profiles back to Error (3 mutations) 3, 1 and 2 tests (each includes the spawn case where one exists)
All closed sites back to Error in review, retained-images, retained-startup, restore, https and cache (6 mutations) 8, 6, 5, 6, 8 and 3 tests (each includes its spawn case where one exists)
Review coded sites back to Error 2 review tests
network, host-aliases, selection, runtime-metadata coded back to Error (4 mutations) 2 (including the spawn case), 1, 1 and 1 tests
finalization, recovery, cleanup-command, process closed back to Error (4 mutations) 6, 5, 1 and 3 tests
restart-preflight admission back to Error admission test
down: 4 sites, each back to Error 4, 4, 1 and 1 tests
exec: identity, argument validation back to Error 4 and 1 tests
one-off: identity, argument validation, not started back to Error 2, 1 and 1 tests
dependency-refresh: identity, not started back to Error 1 and 1 tests
interrupted-cleanup refused() back to Error; NativePartialShutdownError extends Error 4 tests; 2 tests
Each start integrity site back to the configuration refused() (6 mutations) the matching new start test
dispatch run passthrough removed 8 parse cases and the spawn case
Authored detach refusal removed 4 authored-command tests
Preflight phase === "stopped" the initializing case
failedAdmission drops the wrapped cause the new wrapped-cause test

Verifier round 3

Head ead8383, a fast-forward on 4d85f56. An independent verifier found three image-project up refusals that still printed ERROR: Error: … and a stack. next behaves the same way, so these were sites the PR missed, not regressions. Each now prints one closed line.

  1. Image admission (native-project-image-admission.ts). Three cases now print a closed line:
    • A reference the runtime refuses (registry_image), or a service with no image, gives E_NATIVE_PROJECT_UNSUPPORTED with the existing message. The message does not include the reference.
    • A malformed runtime admit-image reply (for example {}) is now a closed integrity error: "Native runtime returned an invalid image admission response; …". Before, it reused the refusal text.
    • A cancel during admission is now a closed "Native image admission was canceled" error. Before, it was reported as a reference refusal.
  2. Prepared base (native-prepared-base.ts). The three invalid HACK_NATIVE_PREPARED_BASE / _STORE selections are now E_CONFIG_INVALID. They still refuse before any runtime request.
  3. Workspace cache (native-workspace-cache.ts). refused() is now E_CONFIG_INVALID, like its sibling adaptation refusals.
  4. restart --detach. This body claimed restart --detach refuses with E_USAGE. restart has no --detach option, so it is a parse error that prints the help, and that is unchanged. The up --detach sections above are corrected.

The new spawn cases are in tests/native-refusal-cli.test.ts. The stub runtime can now also fail a request with a closed stderr code. Each case checks for exactly one ERROR: line, no stack, no Error: , no help, and the runtime calls made:

  • a refused ghcr.io/example/app:1 reference (the output does not contain ghcr.io);
  • a {} admission reply;
  • HACK_NATIVE_PREPARED_BASE=bogus, with no runtime request;
  • an adaptation file {"version":1,"workspaceCache":{"bogus":1}}, which stops after runtime check-project-share.

tests/native-prepared-base.test.ts now asserts the code with expectCodedThrow.

Mutation proofs (Bun 1.4.2). In each case the source was changed as described, the spawn file was rerun, and the source was then restored:

Mutation Failing spawn tests
native-project-image-admission.ts reverted to 4d85f56 refused reference and malformed reply (2), each showing ERROR: Error: …
only malformed() back to a plain Error malformed reply (1)
native-prepared-base.ts reverted to 4d85f56 prepared-base (1)
native-workspace-cache.ts reverted to 4d85f56 workspace cache (1)

Gates on ead8383 (Bun 1.4.2):

  • bun run --cwd packages/cli typecheck is clean. check reports 0 errors and the same 34 warnings. The changed files are formatted with Biome.
  • 495/495 pass across 44 files: every native-project-* and native-runtime-* test file, plus native-prepared-base, native-workspace-cache, native-refusal-cli (16/16), native-selection-refusals, cli-spawn-env-isolation, cli-help and cli-reference-docs.
  • After bun run build and bun run build:config-compiler, all six check-native-*-plan-cli scripts, check-native-config-import-cli and check-config-compiler-cli exit 0.
  • gitleaks 8.30.1 with .gitleaks.toml and --redact found no leaks. The agent sandbox blocks the gitleaks git subcommand, so the scan used gitleaks dir over git log -p --unified=0 origin/next..HEAD (6 commits, about 238 KB).
  • The full bun run test was not rerun this round.

🤖 Generated with Claude Code

roodboi and others added 2 commits October 19, 2026 13:54
Flags after a child command now belong to the child: `hack run web bun
--version` no longer prints Hack's version. Positionals marked
`passthrough` (run, exec, host exec, env exec, session exec, x) end Hack's
option parsing at the child's first word; a leading `--` stays optional and
a later `--` reaches the child unchanged.

Known native refusals print one closed line instead of a stack: the
configuration refusal is E_NATIVE_PROJECT_UNSUPPORTED, the shared-source
grant is E_PERMISSION, and the remaining value-free native start, run,
exec, down and restart refusals are ClosedCliError. Usage refusals thrown
by a handler print the message and a one-line help pointer instead of the
full help. Native up --detach/--json/--target/services refuse by name with
the supported foreground form.

Authored native up checks runtime status before reserving its startup
intent, so a stopped runtime refuses with E_DOCKER_UNAVAILABLE and leaves
ps at not_started instead of pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A child `--json` no longer selects Hack's JSON error envelope: only argv
before the passthrough child command and before the first `--` counts.

A cancel or deadline during the authored start's runtime status check now
refuses before the startup intent is reserved, so ps stays not_started.
A start that fails because authored foreground control reports a stopped
runtime wrapped in native_graph_foreground (#331) now reports
E_DOCKER_UNAVAILABLE like the direct runtime_not_running code; the serve
exit diagnostic carries the bounded cause code to the start error.

Native project input and route refusals are E_NATIVE_PROJECT_UNSUPPORTED;
branch, adaptation, AWS and fresh-exec environment refusals are
E_CONFIG_INVALID; source admission and restart preflight refusals are
closed. Each prints one line without a stack.

`session exec` keeps its single quoted command: an unquoted command now
refuses with a hint to quote it, and its help and docs say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi force-pushed the wf/g1-cli-refusals-b17 branch from 75e4fe5 to c5b4202 Compare October 19, 2026 18:12
@linear-code

linear-code Bot commented Oct 20, 2026

Copy link
Copy Markdown

HACK-1269

roodboi and others added 5 commits October 20, 2026 09:12
Complete the native refusal audit on the image-project up, restart, run,
exec and ps paths. Configuration refusals carry a stable code
(E_CONFIG_INVALID, E_NATIVE_PROJECT_UNSUPPORTED or E_PERMISSION).
Integrity, ownership and change refusals are closed, so the CLI prints one
line without a stack. Start-time receipt, ownership, dependency-plan and
HTTPS verification failures no longer reuse the configuration refusal.

Mark the dispatch run command as passthrough, so a child --version reaches
the child. Give authored up/restart --detach the image project's one-line
E_USAGE refusal.

Add a CLI spawn test with a stub runtime for representative refusal sites,
class and code assertions for the round-1 conversions, a non-running phase
preflight case, and a failedAdmission wrapped-cause test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On macOS, authored up --detach now refuses with the image project's
one-line E_USAGE message. The native plan CLI acceptance scripts expected
E_NATIVE_PROJECT_UNSUPPORTED on every platform; expect E_USAGE on macOS
and keep E_NATIVE_PROJECT_UNSUPPORTED elsewhere.

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

Three image-project `up` refusals still printed `ERROR: Error: ...` with a
stack. They now print one closed line:

- An image reference the runtime refuses (`registry_image`) or an authored
  service without an image is `E_NATIVE_PROJECT_UNSUPPORTED`. A malformed
  `runtime admit-image` reply is a closed integrity error, and a canceled
  admission no longer reports a reference refusal.
- An invalid HACK_NATIVE_PREPARED_BASE / _STORE selection is
  `E_CONFIG_INVALID`.
- An invalid workspace cache layout in HACK_NATIVE_ADAPTATION is
  `E_CONFIG_INVALID`, like its sibling adaptation refusals.

Spawn tests drive each case through the real CLI with a stub runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings #355 (G4, HACK-1272: retire authored starts refused before journal
reservation), #352, #341, #342 and #346 into the G1 closed-refusal branch.
Both sides' behavior is kept.

Conflict resolutions:

- docs/reference/native-authored-commands.md: keep G1's explanation of
  `pending` (in-progress start or failed start with retained evidence; only
  the owner's cleanup proof may retire it) and append G4's pointer that
  `down --recover` retires an attempt that never reserved runtime state when
  no startup is running.

- src/lib/native-authored-command.ts: the `pending` status note keeps G1's
  wording and adds G4's `hack down --recover` hint. Auto-merged code keeps
  G4's canceled `unreserved` exit 130 ahead of G1's stopped-runtime
  classification. Semantic fix: G1's stoppedRuntimeStartup message map had
  no entry for G4's new `unreserved` outcome, so a runtime_not_running start
  that was retired as unreserved would have produced an undefined message.
  It now reports E_DOCKER_UNAVAILABLE with an `unreserved` message (intent
  retired, `hack ps` reports not_started). G4's `down --recover` pending-start
  path already refuses through commandFailure as a closed coded HackCliError
  (E_LIFECYCLE_FAILED, values omitted), and unreserved startup failures map
  to E_STARTUP_INCOMPLETE or E_DOCKER_UNAVAILABLE, so no stack or help is
  printed.

- tests/native-authored-project-start.test.ts: the fixture keeps G1's
  `stoppedDuringServe` and G4's `failBeforeReceiptCode` and
  `unreservedInspection` options. The stub serve emits G4's configurable
  failBeforeReceipt code and G1's wrapped stopped-runtime refusal. The stub
  inspect answers both failure modes, with `native_graph_unreserved` only
  when requested. The duplicate native-runtime-client import is merged into
  one. Semantic fix: G4's HACK-1272 call-log expectation now includes G1's
  read-only `runtime status --json` preflight between plan and serve.
  Both sides' tests are kept.

- tests/native-authored-command.test.ts (auto-merged): add an `unreserved`
  case to the stopped-runtime startup table.

Verification (Bun 1.4.2, macOS):
- packages/cli typecheck and check: 0 errors.
- native-authored-command, native-refusal-cli,
  native-authored-command-dispatch and cli-spawn-env-isolation: 124 pass.
- Other native-authored-* and native-runtime-* suites: 409 pass, 5 fail.
  2 are load-timing failures that pass when rerun alone. 3 are host
  live-session census failures in the recovery and incarnation suites,
  which fail identically on origin/next and ead8383 on this host.
- native-authored-project-start (--timeout 60000): 48 pass. The 3
  host-metadata cases are intermittent (HACK-1299). They also fail
  intermittently on origin/next and ead8383 and pass when run alone.
- Six scripts/check-native-*-plan-cli.ts: all pass after build and
  build:config-compiler.
- docs:cli-reference and generate:agent-plugins: no drift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi marked this pull request as ready for review October 21, 2026 17:54
@roodboi
roodboi merged commit ace57ee into next Oct 21, 2026
17 checks passed
@roodboi
roodboi deleted the wf/g1-cli-refusals-b17 branch October 21, 2026 17:54
roodboi added a commit that referenced this pull request Oct 21, 2026
#340 is now squash-merged into next (9a80dbd), so next and the F7 head
(e99f68c) hold the same F7 text. Conflicts were resolved against that
head as the effective base, keeping F8's changes and next's other work:

- docs/env.md, src/agents/pattern-catalogue.ts,
  src/lib/project-env-config.ts: F8's text. Next's side was F7's text,
  which F8 had already extended (hydrate pointer, P08 card, CAS
  expectedExisting option).
- src/cli/command.ts: keep #359's single-valued passthrough refusal
  exactly; other extra positionals are still reported by count only.
- plugins/.../patterns.md: next's version, then regenerated (output
  equals F8's render).
- src/lib/fs.ts merged without a conflict marker but duplicated
  writeTextFileAtomicIfChanged, AtomicWriteRefusedError and
  inspectAtomicWriteTarget; restored F8's single CAS-aware copy, which
  next's F7 version is a subset of.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi added a commit that referenced this pull request Oct 22, 2026
…losed refusals (#365)

## Summary

User decision (2026-10-19): in v5, unchanged real Compose projects such as Omega stay on the
supported Compose backend. Native support for these features is post-v5 work (HACK-1270). This
PR makes native `hack up` and `hack restart` refuse each blocking Compose feature exactly,
before any mutation. It also documents the features and adds a `hack doctor` check that reports
them.

The explicit `HACK_NATIVE_ADAPTATION` path stays supported. A network that a valid adaptation
lists in `isolatedNetworks` is not treated as an external network: the check classifies the
Compose file after that adaptation, as native start does.

### Root cause

Native image-project `up` (`handleNativeUp` → `startNativeProject`) had no early classification
of Compose-only features:

- **External networks** and **non-arm64 `platform`** were not checked on the TypeScript side.
  They failed only after `runtime check-project-share`, run-storage preparation, lifecycle
  `before` hooks and `runtime up` (the runtime-core review rejects `external_network`, and
  registry admission selects arm64 only).
- **Builds** and **labels** were refused, but only as the general
  `NATIVE_CONFIGURATION_REFUSAL`. That came after the source preflight and storage preparation,
  and it did not say the project belongs on the Compose backend.

### Rework after verification (adaptation regression)

The first version of this PR (9ce0f3b) read only the original `.hack/docker-compose.yml`. It
ignored the documented `HACK_NATIVE_ADAPTATION` `isolatedNetworks` selection, which native start
applies to the normalized copy (`replaceNetworks` sets `x-hack-isolated`). So
`"isolatedNetworks": ["hack-dev"]` with an external `hack-dev` network was refused with the
Compose-backend line before the adaptation was read. That blocked `up`, `restart` and `restart
--recover`, and native doctor warned about it. On `next` the same project passes native review
and reaches `runtime check-project-share` and `runtime up`.

Fix (76003b5):

- `findComposeBackendFeatures(compose, {isolatedNetworks})` does not count a network named in
  the set as external. Other features are unaffected.
- `readComposeBackendFeatures(composeFile, {adaptationFile})` reads the selection only when the
  unadapted file has an external network. A project without one keeps its existing refusal
  order.
- `readAdaptedIsolatedNetworks` reads the file with native start's own `readNativeSelection`
  (absolute path, bounded, unchanged regular file). If the file cannot be read, the selection is
  not version 1, or `isolatedNetworks` is not a bounded list of unique strings, it refuses with
  the same one-line `E_CONFIG_INVALID` message native start gives, before any runtime request.
  Native start still validates the whole selection against the normalized Compose file,
  including that each listed name is an external network.
- `handleNativeUp` (covers `up`, `restart` and `restart --recover`) passes
  `process.env.HACK_NATIVE_ADAPTATION`, the same value it gives `startNativeProject`. Native
  doctor passes the same value. Compose doctor does not, because the adaptation is native-only.
- Docs: the "External network" row, the refusal-contract paragraph and the doctor paragraph in
  `docs/guides/native-candidate.md` now describe the adaptation. The adaptation section links
  back to the Compose-backend section, and `docs/reference/native-config-import.md` qualifies
  its summary. The two sections no longer contradict each other, and `isolatedNetworks` is not
  retired.

### Fix (unchanged from the first version)

- `src/lib/native-compose-backend-features.ts` classifies the project's Compose file into a
  closed, ordered set: `external_network`, `reserved_label`, `unsupported_build`,
  `foreign_platform`.
  - **External network**: top-level `external: true` or `external: {name}`, unless it is
  `x-hack-isolated: true` or isolated by the adaptation.
  - **Reserved ownership labels**: keys starting with `com.docker.compose.`, `io.hack-local.` or
  `io.hack.native-config.`, on service, network and volume labels. Routing (`caddy*`), cache
  (`hack.dependencies.*`), one-shot and other ordinary labels keep their native behavior.
  - **Build**: reuses the import preview's `mapLegacyComposeBuild`. The result matches what
  `hack config import --dry-run` reports as `invalid_or_unsupported_build` or
  `image_build_exclusive`.
  - **Platform**: anything other than `linux/arm64` or `linux/arm64/v8`.
- `handleNativeUp` calls `refuseNativeComposeBackendFeatures` after option refusal and project
  selection, before branch, config, env-fingerprint, lifecycle, shared-source, storage and
  runtime work. `--detach` still gets its one-line `E_USAGE` first (#359). `down` is not
  affected. Authored and adopted native-config projects go through `tryNativeComposeCommand`
  first and never reach this check.
- Each refusal is a `HackCliError` with the stable code `E_NATIVE_PROJECT_UNSUPPORTED` and one
  value-free line that names every feature found and the `HACK_RUNTIME_BACKEND=compose`
  selection. `detail` is `{backend: "compose", features: [...]}`.
- Unreadable, oversized (over 256 KiB), symlinked or unparseable Compose files produce no
  features. Native input review still refuses them as before.
- `hack doctor` has a `compose backend features` check, in both native and Compose doctor. On
  native, a feature found is a `warn`. On Compose it is informational (`ok`). With `--json`, the
  check carries `composeBackend.features` (closed names only).

The CLI surface (options and help) is unchanged, so `docs/reference/cli.md` was not regenerated.

## Tests

- `tests/native-compose-backend-features.test.ts` (unit, 13 tests). The original cases are kept.
  New in the rework:
  - An adaptation-isolated network is not external. A parity check runs the real
  `adaptNativeProject` on the same selection and classifies its normalized output the same way.
  A network that is not listed is still external, and isolation never hides another feature.
  - A file classified with a valid isolating selection has no features, while a selection that
  lists another network or has no `isolatedNetworks` leaves the external network.
  - Five invalid selections refuse with `E_CONFIG_INVALID` and the exact message. For each one,
  the real `adaptNativeProject` throws the same message. Without an external network the
  selection is not read. Missing and relative paths refuse.
- `tests/native-compose-backend-features-cli.test.ts` (real CLI through `privateCliEnv`, stub
  runtime, 15 tests). The original cases are kept. New in the rework:
  - The verifier's repro: external `hack-dev` + `{"version":1,"isolatedNetworks":["hack-dev"]}`
  + `HACK_NATIVE_SHARED_SOURCE=1`. `hack up` passes the check and reaches `runtime check-
  project-share` and then `runtime up`, as on `next`.
  - `hack restart` with the same selection reaches restart preflight's own `Native restart has
  no existing run` line.
  - A selection that isolates a different network still gets the Compose-backend line, with 0
  runtime calls and an unchanged tree.
  - An invalid selection (version 2) gets one `E_CONFIG_INVALID` line, with 0 runtime calls and
  an unchanged tree.
  - Native `doctor --json` with the selection reports `ok` and `features: []`.
- Mutation proof for the rework. Each mutation was applied, both files run, and the change
  reverted with `git checkout`:

| Mutation | Failing tests |
  | --- | --- |
  | `handleNativeUp` does not pass the adaptation | 3 (up, restart, invalid selection) |
  | Classifier ignores the isolated set | 5 |
  | Selection read even without an external network | 1 |
  | `isolatedNetworks` shape not validated | 1 |
  | `version` not validated | 2 |
  | Native doctor does not pass the adaptation | 1 |

The first version's mutation results (removing the call, each rule, doctor wiring) still apply
to the unchanged cases.

## Evidence

- Bun 1.4.2 (`mise x bun@1.4.2`), macOS arm64 host. Load average was between 30 and 95 during
  these runs.
- `bun run --cwd packages/cli typecheck`: 0 errors. `bun run --cwd packages/cli check`: 0 errors
  and 34 existing warnings. `biome check` on the changed files is clean.
- Focused and required: the two PR test files, `tests/cli-spawn-env-isolation.test.ts`,
  `tests/cli-reference-docs.test.ts`, `tests/integration-ownership.test.ts` and `tests/native-
  refusal-cli.test.ts`: 63 pass, 0 fail.
- `tests/doctor-*.test.ts`, `tests/native-project-adaptation.test.ts` and `tests/native-config-
  import*.test.ts`: 539 pass, 3 fail. The 3 failures are the capture process-group tests in
  `tests/native-config-import-build.test.ts` (about 505 ms each). The verifier found the same
  failures with `origin/next` source, so they come from this host, not this PR.
- After `bun run build` and `bun run build:config-compiler`, all six `scripts/check-
  native-*-plan-cli.ts` exit 0. No native refusal code or message changed. The
  `E_CONFIG_INVALID` adaptation message is native start's existing one.
- gitleaks 8.30.1: no leaks. The scan ran as `gitleaks dir` on a `git log -p origin/next..HEAD`
  export, because the worktree sandbox blocks `gitleaks git`.
- Full `bun run test`: exit 1 after 1 h 27 min on the loaded host. 114 files had failures in the
  parallel run. A serial rerun of each with `--timeout 60000` left 20 files failing. 19 are
  authored/adopted native-compose suites, which never reach `handleNativeUp`'s check, and their
  failures are fixture timeouts and unsettled children. The 20th, `native-env-metadata`, passed
  32/32 on a later rerun with this PR's code. As a baseline, `src/commands/project.ts` and
  `src/commands/doctor.ts` were swapped to the merge base `ec223cdb`. `native-compose-down-
  command` (1/13), `native-compose-storage-fence-cost` (0/4), `native-compose-retained-storage-
  command` (0/5) and `native-authored-process-incarnation` (9/1) failed the same way, so these
  failures are inherited under this load.
- The adaptation CLI case also accepts a timed-out or unreadable stub reply (94e12a9). Under
  load, both appeared in the parallel run, and each is reachable only after the check passed.
  The original `control: ordinary labels…` case is sensitive to the same load in the parallel
  run and passes serially.
- Hosted CI on 76003b5: every job passed except docker-e2e shards 3 and 4, which hit the same
  inherited failures as on 9ce0f3b:
  - Shard 3: the `pattern-p01-dependency-bootstrap` `docker inspect … No such object` race.
  - Shard 4: `native-config-build` and `native-config-down-hooks` (also failing on the `next`
  base run 39663080763).

The run on 94e12a9 was in progress when this was written. Shards 1–3 and all other completed
jobs passed.

## Remaining gaps

- The Compose-doctor variant is covered by a unit test of its result, not a CLI run, because the
  private CLI harness does not stub its Docker-era checks.
- Only the explicit `platform` field is classified. An image with no `platform` that publishes
  only amd64 is still refused later, at native image admission, with the existing image refusal.
- Only `.hack/docker-compose.yml` is classified. Compose override files and env-overlay
  interpolation are not evaluated. An interpolated `platform` such as `${PLATFORM}` is treated
  as non-native.
- The check validates only the `version` and `isolatedNetworks` parts of the adaptation. Native
  start still validates the rest of the selection, and that each listed name is an external
  network, in its existing order. A listed network declared in the legacy `external: {name}`
  form passes this check. Native start's adaptation then decides it as before, because
  `replaceNetworks` accepts only `external: true` in the normalized file.
- Native support for these features stays in HACK-1270. Real Omega acceptance on the M3 has not
  been run here.

Refs HACK-1291

🤖 Generated with [Claude Code](https://claude.com/claude-code)
roodboi added a commit that referenced this pull request Oct 22, 2026
…#370)

## Root cause

`parseCliArgv` parsed argv once against the union of every command's options, keyed by long name
(`buildUnionParseOptions`). Two options shared a short flag: `--path -p` (project commands) and
`ssh --port -p`. Bun's `parseArgs` resolved `-p` to whichever came last, which was `ssh --port`.
So `hack up -p <dir>` (and `init`, `down`, `ps`, `logs`, `env ...`, `config ...`, `setup ...`)
parsed as `--port`, and `run.ts` refused it with `Option(s) not valid for "up": --port`. The
same union let one long name's type in one command change how every other command parsed it
(#339 hit this with `--rollback`), which is why a guard test banned cross-command type
conflicts.

## Fix

`src/cli/command.ts` now parses in two passes:

1. **`scanInvocation`** resolves the command path, and the child-command boundary for
passthrough positionals, from the leading positionals. It reads each option with the options of
the command resolved so far, plus the global and built-in options, and skips that option's value
token, including the omitted-value form of options with a default. An option placed before the
command name falls back to the other declarations of that spelling. If those declarations
disagree about taking a value, the option is read as a flag, so it never consumes the command
name. 2. **`parseCliArgv`** then parses with that command's own options plus the global and
built-in options, by long and short spelling. Options that only other commands declare are still
parsed by long name, never by short flag. A misplaced option therefore keeps its existing
`Option(s) not valid for "<command>"` refusal.

`findPassthroughStart` and `hackOwnedArgv` use the same scan, so #359's child-argv passthrough
uses the resolved command's option types. That covers `--` handling, flags after the child
command, and the JSON envelope selected only by Hack's own `--json`. Default-value normalization
is scoped the same way. `run.ts` is unchanged.

The only parse difference found by comparing old and new output on edge cases (`--help --all`,
`help --all`, `-- up`, `--json run web tool --json`, `--path /x up`, `logs --tail`, `up --tail`,
`ssh --path /x`, `x ext cmd -p 1`, `--no-interactive up -p /x`) is the intended one: `up -p /x`
now sets `path` instead of `port`.

## Tests

- New `tests/cli-per-command-options.test.ts`:
  - `-p` sets `--path` for every command that declares `--path -p`. The test walks the spec and
  asserts that it includes init/up/down/ps/logs/env list/config validate/setup migrate.
  - `ssh -p` sets `--port`.
  - `-p` before the command name follows that command.
  - A misplaced foreign long option is still refused as "not valid here", while an unknown short
  flag is unknown.
  - `config adopt --rollback --json` stays boolean, and `setup migrate --rollback-manifest
  <file>` takes a value.
  - A synthetic CLI where `--rollback`/`-r` is a flag in one command and takes a value in
  another parses correctly per command, before the command name, and at the passthrough
  boundary.
  - A source-CLI spawn under `privateCliEnv` checks that `hack ps -p <dir> --json` no longer
  reports `--port`.
- `tests/cli-command.test.ts`: the cross-tree "one value type per long option" guard is replaced
  by a per-scope guard. Within a command's scope (built-ins, globals and the command's own
  options), each long and short spelling must name exactly one option.
- The #359 passthrough tests in `tests/cli-passthrough-argv.test.ts` are unchanged and pass.

Each new test was checked by mutation:
- With the old `command.ts`, 11 of the new tests fail, including the spawn test. It printed
  `Option(s) not valid for "ps": --port`.
- Making disagreeing declarations keep the last type fails the before-command-name test.
- Removing other commands' long names from the parse table fails the "not valid here" test.
- Giving `ssh --port` the short flag `-h` fails the per-scope guard with `hack ssh: -h names
  --help and --port`.

## Evidence

Bun 1.4.2 (`mise x bun@1.4.2`), base ace57ee. `next` has since moved to f2278c7; it only adds
an unrelated `run.ts` hunk and merges cleanly.

- `bun run --cwd packages/cli typecheck`: 0 errors. `bun run typecheck`: passes.
- `bun run --cwd packages/cli check` and `bun run check`: 0 errors. The changed files lint
  clean, with no new warnings.
- Focused suites pass: 131/131 for the new test file, cli-passthrough-argv, cli-command, cli-
  spawn-env-isolation, cli-reference-docs, integration-ownership, mcp and mcp-inspect-tools. One
  run had a transient failure in an `mcp.test.ts` spawn test under load; it passed alone and in
  the rerun.
- `bun run docs:cli-reference`: no change to `docs/reference/cli.md`, because the help surface
  is unchanged.
- After `bun run build`, `dist/hack ps -p <dir> --json` and `dist/hack up -p <dir> --json`
  (private HOME) reach project discovery ("No .hack/ found") instead of the `--port` refusal.
- No native refusal codes or messages changed, so the native plan-CLI scripts were not rerun.
- gitleaks 8.30.1 over a `git log -p origin/next..HEAD` export: no leaks.

## Remaining gaps

- Full `bun run test` ran on a heavily loaded host for 4253s and ended with 6824 pass and 640
  fail across 105 files. Rerun serially with `--timeout 60000`, 89 of those files pass.
  - The 16 that still fail are native-compose/native-authored files. Rerun with `origin/next`'s
  `command.ts` swapped in, they fail the same way or worse; for example, `native-compose-
  adoption-generation` fails 0/111 on both.
  - Two files with a single failure each (`dependency-startup-progress`, `native-compose-
  adoption-probe`) pass alone, and neither imports the CLI parser.
  - None of these failures is caused by this change. A green full run on a quiet runner or CI is
  still outstanding.
- Edge case, documented in `scanInvocation`: an option placed before the command name whose
  spelling is a flag in one command and takes a value in another is read as a flag. No such
  spelling exists in the current CLI.

Refs HACK-1293

🤖 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