fix(minimal): don't scaffold over an existing config on non-interactive activate - #758
Conversation
…ve activate `min activate .` on a project whose config lives at `.minimal/minimal.toml` would silently overwrite that config with a generated template in any non-interactive context (CI, piped/EOF stdin, an agent), destroying the user's tasks and package lists. Two independent flaws combined: - Detection used a naive `project_path.join(MFILE_NAME).exists()` check that only understands the root `<project>/minimal.toml` layout, so on a `.minimal/` project it missed and fell through to the prompt. - `confirm()` returns `true` on empty/EOF input, so non-tty stdin default-YES'd the "create one?" prompt, and the init writer resolved the real `.minimal/minimal.toml` path and clobbered it. Fix A: detect via `mfile::File::from_dir` — the same resolver the CLI loads config with (root OR `.minimal/`) — so an existing config is recognized and activation proceeds untouched. Fix B: gate the scaffold prompt on `std::io::stdin().is_terminal()`. On non-interactive stdin we bail with the existing "run 'minimal init'" hint instead of default-YES-ing a destructive create. Gated at the call site (not in shared `confirm()`) so the other, non-destructive `confirm()` caller keeps its empty-defaults-yes behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthrough
ChangesConfiguration prompting
Estimated code review effort: 3 (Moderate) | ~15–30 minutes Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
Comment |
…it prompt When a session shell exits, the daemon shows an `async_dialog::Select` prompt (Detach / Delete) and blocks on the SSH channel for the answer. A non-interactive attach — a scripted client, or one whose connection dropped — has no one to answer, so the session host wedges on the prompt forever (the reader never sees a keypress, nor an EOF, since a forced-pty client delivers stdin close as an in-band Ctrl-D rather than a channel EOF). Bound the prompt with a short grace period, defaulting to the safe Detach (identical to the existing At(0)/Cancelled arms) on timeout. This mirrors the non-tty guard in #758: no interactive prompt may block a non-interactive caller. A gate on "no PTY requested" cannot serve here: `shell_request` only ever grants a session shell with a PTY (it rejects a shell otherwise), so at the exit prompt a PTY is always present — the pty-absence signal is unreachable. A bounded wait is the reachable equivalent, and it also covers a dropped or idle interactive client. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… min add)
The shared session e2e only proved the lifecycle (activate -> ls ->
destroy -> stop); it never forked a session sandbox, so it could not
catch a break in the real in-sandbox user path. Deepen it, on the native
lane (DM2), to attach an INTERACTIVE PTY shell — which forks a real
hakoniwa sandbox — and prove the in-sandbox `min` helper:
- self-seed a throwaway project (mktemp -d) with the repo's own pinned
[upstream] and a light shell stack, and activate THAT, not the repo
root (a non-interactive activate over the repo root scaffolds over
.minimal/minimal.toml; fixed crate-side in #758).
- pipe an interactive `min attach <sid>` a command stream that records a
non-baseline tool (tar) as absent, `min add`s it into the session,
then runs it. Its version banner round-tripping proves `min add`
reached the daemon over the /run/minenv_sock relay and hardlinked the
package into the live rootfs. On `exit` the session-exit prompt
auto-detaches this non-interactive client (the minimald fix in this
same PR), so the attach returns exit 0.
The proof is gated off the VM lanes (E2E_VM): there the sandbox forks
inside the guest, whose compose/cache and bridge-driven attach this
harness cannot validate; those stay proven in Rust. VM lanes run the
lifecycle proof only — no faked coverage. The existing structure
(set -uo pipefail, mnl(), fail()/teardown(), ::group::/::error::,
millisecond timing) is preserved; shellcheck is clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…it prompt When a session shell exits, the daemon shows an `async_dialog::Select` prompt (Detach / Delete) and blocks on the SSH channel for the answer. A non-interactive attach — a scripted client, or one whose connection dropped — has no one to answer, so the session host wedges on the prompt forever (the reader never sees a keypress, nor an EOF, since a forced-pty client delivers stdin close as an in-band Ctrl-D rather than a channel EOF). Bound the prompt with a short grace period, defaulting to the safe Detach (identical to the existing At(0)/Cancelled arms) on timeout. This mirrors the non-tty guard in #758: no interactive prompt may block a non-interactive caller. A gate on "no PTY requested" cannot serve here: `shell_request` only ever grants a session shell with a PTY (it rejects a shell otherwise), so at the exit prompt a PTY is always present — the pty-absence signal is unreachable. A bounded wait is the reachable equivalent, and it also covers a dropped or idle interactive client. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… min add)
The shared session e2e only proved the lifecycle (activate -> ls ->
destroy -> stop); it never forked a session sandbox, so it could not
catch a break in the real in-sandbox user path. Deepen it, on EVERY lane
(native DM2, KVM DM3, macOS DM1), to attach an INTERACTIVE PTY shell —
which forks a real hakoniwa sandbox (on a VM lane, inside the guest, over
the vsock bridge) — and prove the in-sandbox `min` helper:
- guarantee the activate target carries a host-side `minimal.toml`: the
native lane self-seeds a throwaway `mktemp -d` (repo's pinned
[upstream] + a light shell stack), never the repo root; when the
caller sets E2E_PROJECT_DIR (VM lanes pass /tmp) we seed one there if
missing. Since #758 a non-interactive `min activate` over a
config-less dir bails, so this is the harness-side complement.
- pipe an interactive `min attach <sid>` a command stream that records a
non-baseline tool (tar) as absent, `min add`s it into the session,
then runs it. Its version banner round-tripping proves `min add`
reached the daemon over the /run/minenv_sock relay and hardlinked the
package into the live rootfs. On `exit` the session-exit prompt
auto-detaches this non-interactive client (the minimald fix in this
same PR), so the attach returns exit 0.
The proof is un-gated: the `min` CLI abstracts where the daemon lives, so
the identical attach+add+run runs on all three lanes (the script's design
premise), and the VM lanes exercise the more valuable bridge-exec path.
Structure preserved (set -uo pipefail, mnl(), fail()/teardown(),
::group::/::error::, millisecond timing); shellcheck clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a session shell exits, the daemon shows a Detach/Delete prompt and blocks on the SSH channel for the answer. A non-interactive attach — a scripted or piped client — has no one to answer, so the session host wedges on the prompt forever (a forced-pty client delivers stdin close as an in-band Ctrl-D, never a channel EOF the reader could detect). Have the `min` client report whether its own context is a terminal (both stdin and stdout a tty) via a MINIMAL_INTERACTIVE env var, independent of the forced `-tt`. The session host consults it at the exit prompt: an interactive client is prompted as before; a non-interactive one skips the unanswerable prompt and deletes the session on exit, so an ephemeral attach cleans up after itself instead of orphaning a session. Mirrors the non-tty bail in #758: no interactive prompt may block a non-interactive caller. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shared session e2e only proved the lifecycle; it never forked a session sandbox, so it could not catch a break in the real in-sandbox user path. Deepen it, on every lane (native, KVM, macOS), to attach a PTY shell — which forks a real hakoniwa sandbox (on a VM lane, inside the guest over the vsock bridge) — and prove the in-sandbox `min` helper: record a non-baseline tool (tar) as absent, `min add` it into the session, then run it; its version banner round-tripping proves `min add` reached the daemon over the /run/minenv_sock relay and hardlinked the package into the live rootfs. The piped attach is non-interactive, so on `exit` the daemon deletes the session (the minimald change in this branch); the script verifies the delete-on-exit rather than issuing a separate `min destroy`. A host-side minimal.toml is seeded so the client pre-flight neither prompts nor bails (#758); every seed is removed on teardown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a session shell exits, the daemon shows a Detach/Delete prompt and blocks on the SSH channel for the answer. A non-interactive attach — a scripted or piped client — has no one to answer, so the session host wedges on the prompt forever (a forced-pty client delivers stdin close as an in-band Ctrl-D, never a channel EOF the reader could detect). Have the `min` client report whether its own context is a terminal (both stdin and stdout a tty) via a MINIMAL_INTERACTIVE env var, independent of the forced `-tt`. The session host consults it at the exit prompt: an interactive client is prompted as before; a non-interactive one skips the unanswerable prompt and deletes the session on exit, so an ephemeral attach cleans up after itself instead of orphaning a session. Mirrors the non-tty bail in #758: no interactive prompt may block a non-interactive caller. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shared session e2e only proved the lifecycle; it never forked a session sandbox, so it could not catch a break in the real in-sandbox user path. Deepen it, on every lane (native, KVM, macOS), to attach a PTY shell — which forks a real hakoniwa sandbox (on a VM lane, inside the guest over the vsock bridge) — and prove the in-sandbox `min` helper: record a non-baseline tool (tar) as absent, `min add` it into the session, then run it; its version banner round-tripping proves `min add` reached the daemon over the /run/minenv_sock relay and hardlinked the package into the live rootfs. The absence marker is built at runtime (printf "TOOL_%s_BEFORE") so the PTY echo of the command stream cannot satisfy the assertion — the literal appears only as executed output. The piped attach is non-interactive, so on `exit` the daemon deletes the session (the minimald change in this branch); the script verifies the delete-on-exit rather than issuing a separate `min destroy`. A host-side minimal.toml is seeded so the client pre-flight neither prompts nor bails (#758); every seed is removed on teardown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a session shell exits, the daemon shows a Detach/Delete prompt and blocks on the SSH channel for the answer. A non-interactive attach — a scripted or piped client — has no one to answer, so the session host wedges on the prompt forever (a forced-pty client delivers stdin close as an in-band Ctrl-D, never a channel EOF the reader could detect). Have the `min` client report whether its own context is a terminal (both stdin and stdout a tty) via a MINIMAL_INTERACTIVE env var, independent of the forced `-tt`. The session host consults it at the exit prompt: an interactive client is prompted as before; a non-interactive one skips the unanswerable prompt and deletes the session on exit, so an ephemeral attach cleans up after itself instead of orphaning a session. Mirrors the non-tty bail in #758: no interactive prompt may block a non-interactive caller. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shared session e2e only proved the lifecycle; it never forked a session sandbox, so it could not catch a break in the real in-sandbox user path. Deepen it, on every lane (native, KVM, macOS), to attach a PTY shell — which forks a real hakoniwa sandbox (on a VM lane, inside the guest over the vsock bridge) — and prove the in-sandbox `min` helper: record a genuinely-absent tool (jq — the shell stack composes base + curl, and jq is pulled in by neither) as absent, `min add` it into the session, then run it; its version banner round-tripping proves `min add` reached the daemon over the /run/minenv_sock relay and hardlinked the package into the live rootfs. The absence marker is built at runtime (printf "TOOL_%s_BEFORE") so the PTY echo of the command stream cannot satisfy the assertion — the literal appears only as executed output. The piped attach is non-interactive, so on `exit` the daemon deletes the session (the minimald change in this branch); the script verifies the delete-on-exit rather than issuing a separate `min destroy`. A host-side minimal.toml is seeded so the client pre-flight neither prompts nor bails (#758); every seed is removed on teardown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a session shell exits, the daemon shows a Detach/Delete prompt and blocks on the SSH channel for the answer. A non-interactive attach — a scripted or piped client — has no one to answer, so the session host wedges on the prompt forever (a forced-pty client delivers stdin close as an in-band Ctrl-D, never a channel EOF the reader could detect). Have the `min` client report whether its own context is a terminal (both stdin and stdout a tty) via a MINIMAL_INTERACTIVE env var, independent of the forced `-tt`. The session host consults it at the exit prompt: an interactive client is prompted as before; a non-interactive one skips the unanswerable prompt and deletes the session on exit, so an ephemeral attach cleans up after itself instead of orphaning a session. Mirrors the non-tty bail in #758: no interactive prompt may block a non-interactive caller. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shared session e2e only proved the lifecycle; it never forked a session sandbox, so it could not catch a break in the real in-sandbox user path. Deepen it, on every lane (native, KVM, macOS), to attach a PTY shell — which forks a real hakoniwa sandbox (on a VM lane, inside the guest over the vsock bridge) — and prove the in-sandbox `min` helper: record a genuinely-absent tool (jq — the shell stack composes base + curl, and jq is pulled in by neither) as absent, `min add` it into the session, then run it; its version banner round-tripping proves `min add` reached the daemon over the /run/minenv_sock relay and hardlinked the package into the live rootfs. The absence marker is built at runtime (printf "TOOL_%s_BEFORE") so the PTY echo of the command stream cannot satisfy the assertion — the literal appears only as executed output. The piped attach is non-interactive, so on `exit` the daemon deletes the session (the minimald change in this branch); the script verifies the delete-on-exit rather than issuing a separate `min destroy`. A host-side minimal.toml is seeded so the client pre-flight neither prompts nor bails (#758); every seed is removed on teardown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shared session e2e only proved the lifecycle; it never forked a session sandbox, so it could not catch a break in the real in-sandbox user path. Deepen it, on every lane (native, KVM, macOS), to attach a PTY shell — which forks a real hakoniwa sandbox (on a VM lane, inside the guest over the vsock bridge) — and prove the in-sandbox `min` helper: record a genuinely-absent tool (jq — the shell stack composes base + curl, and jq is pulled in by neither) as absent, `min add` it into the session, then run it; its version banner round-tripping proves `min add` reached the daemon over the /run/minenv_sock relay and hardlinked the package into the live rootfs. The absence marker is built at runtime (printf "TOOL_%s_BEFORE") so the PTY echo of the command stream cannot satisfy the assertion — the literal appears only as executed output. The piped attach is non-interactive, so on `exit` the daemon deletes the session (the minimald change in this branch); the script verifies the delete-on-exit rather than issuing a separate `min destroy`. A host-side minimal.toml is seeded so the client pre-flight neither prompts nor bails (#758); every seed is removed on teardown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shared session e2e only proved the lifecycle; it never forked a session sandbox. Deepen it, on every lane, to attach an interactive shell — which forks a real hakoniwa sandbox (on a VM lane, inside the guest over the vsock bridge) — and prove the in-sandbox `min` helper: record a genuinely-absent tool (jq — the shell stack composes base + curl, and jq is pulled in by neither) as absent, `min add` it, then run it; its version banner round-tripping proves `min add` reached the daemon over the /run/minenv_sock relay and hardlinked the package into the live rootfs. A session is interactive by design, so the proof drives it like a real user through a REAL pty (scripts/e2e-attach-pty.py): pump the command stream, then answer the session-exit Detach/Delete prompt with keystrokes (Down + Enter => Delete). A pipe is not a tty and cannot answer that prompt. Selecting Delete tears the session down, so it must be delisted. Output is matched with a bash glob, not `printf | grep -q`, so `min add`'s progress-bar flood cannot SIGPIPE a pipefail false-negative. A host-side minimal.toml is seeded (uploaded to the session since #748) so the client pre-flight neither prompts nor bails (#758); every seed is removed on teardown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shared session e2e only proved the lifecycle; it never forked a session sandbox. Deepen it, on every lane, to attach an interactive shell — which forks a real hakoniwa sandbox (on a VM lane, inside the guest over the vsock bridge) — and prove the in-sandbox `min` helper: record a genuinely-absent tool (jq — the shell stack composes base + curl, and jq is pulled in by neither) as absent, `min add` it, then run it; its version banner round-tripping proves `min add` reached the daemon over the /run/minenv_sock relay and hardlinked the package into the live rootfs. A session is interactive by design, so the proof drives it like a real user through a REAL pty (scripts/e2e-attach-pty.py): pump the command stream, then answer the session-exit Detach/Delete prompt with keystrokes (Down + Enter => Delete). A pipe is not a tty and cannot answer that prompt. Selecting Delete tears the session down, so it must be delisted. Output is matched with a bash glob, not `printf | grep -q`, so `min add`'s progress-bar flood cannot SIGPIPE a pipefail false-negative. A host-side minimal.toml is seeded (uploaded to the session since #748) so the client pre-flight neither prompts nor bails (#758); every seed is removed on teardown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shared session e2e only proved the lifecycle; it never forked a session sandbox. Deepen it, on every lane, to attach an interactive shell — which forks a real hakoniwa sandbox (on a VM lane, inside the guest over the vsock bridge) — and prove the in-sandbox `min` helper: record a genuinely-absent tool (jq — the shell stack composes base + curl, and jq is pulled in by neither) as absent, `min add` it, then run it; its version banner round-tripping proves `min add` reached the daemon over the /run/minenv_sock relay and hardlinked the package into the live rootfs. A session is interactive by design, so the proof drives it like a real user through a REAL pty (scripts/e2e-attach-pty.py): pump the command stream, then answer the session-exit Detach/Delete prompt with keystrokes (Down + Enter => Delete). A pipe is not a tty and cannot answer that prompt. Selecting Delete tears the session down, so it must be delisted. Output is matched with a bash glob, not `printf | grep -q`, so `min add`'s progress-bar flood cannot SIGPIPE a pipefail false-negative. A host-side minimal.toml is seeded (uploaded to the session since #748) so the client pre-flight neither prompts nor bails (#758); every seed is removed on teardown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… every lane (#760) * fix(checkouts): report exit status on git command failure A failed git command surfaced only its stderr. When git is killed by a signal (e.g. OOM mid-fetch) stderr holds only whatever it wrote before dying — which can be a misleadingly benign line such as a "templates not found" warning, masking that the process was killed rather than failing cleanly. Include the rendered ExitStatus (exit code or signal) in GitCommandFailed and its mctx wrapper, so a signal kill is distinguishable from a git-reported error. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: exercise the session sandbox via interactive attach The shared session e2e only proved the lifecycle; it never forked a session sandbox. Deepen it, on every lane, to attach an interactive shell — which forks a real hakoniwa sandbox (on a VM lane, inside the guest over the vsock bridge) — and prove the in-sandbox `min` helper: record a genuinely-absent tool (jq — the shell stack composes base + curl, and jq is pulled in by neither) as absent, `min add` it, then run it; its version banner round-tripping proves `min add` reached the daemon over the /run/minenv_sock relay and hardlinked the package into the live rootfs. A session is interactive by design, so the proof drives it like a real user through a REAL pty (scripts/e2e-attach-pty.py): pump the command stream, then answer the session-exit Detach/Delete prompt with keystrokes (Down + Enter => Delete). A pipe is not a tty and cannot answer that prompt. Selecting Delete tears the session down, so it must be delisted. Output is matched with a bash glob, not `printf | grep -q`, so `min add`'s progress-bar flood cannot SIGPIPE a pipefail false-negative. A host-side minimal.toml is seeded (uploaded to the session since #748) so the client pre-flight neither prompts nor bails (#758); every seed is removed on teardown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: provision gvproxy for the KVM/macOS session-e2e lanes The KVM and macOS session-e2e lanes boot a microVM whose guest daemon clones the upstream `pkgs` repo during session mint. That clone needs the guest's egress — NAT + DNS — which minvmd provides by spawning gvproxy as the per-host-VM switch. Neither lane provisioned the gvproxy binary, and its resolution is best-effort (never errors), so minvmd booted the VM switchless: the guest's overlay had no other end and every egress — including the clone — died with "Could not resolve host: github.com". Fetch the pinned gvproxy (scripts/fetch-gvproxy.sh, already used by the native minimald-root-integration job) and point minvmd at it via MINVMD_GVPROXY_BIN on each lane's session-e2e step. The macOS lane's fetch auto-selects the gvproxy-darwin asset. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bug
min activate .on a project whose config lives at.minimal/minimal.tomlwould, in any non-interactive context (CI, piped/EOF stdin, an agent),
overwrite that config with a generated template — destroying the user's
tasks and package lists. It was also silently regenerating this repo's own
.minimal/minimal.tomlon every native e2e run (scripts/session-e2e.shrunsmin activate .with non-tty stdin).The fixes
Fix A —
.minimal/-aware detection. Detection now goes throughmfile::File::from_dir— the same resolver the CLI loads config with. In theactivate path the config always carries a
repo_diroverride, soProjectSetup::for_initresolves viaMFileSearchStrategy::Override→mfile::File::from_dir(crates/mctx/src/mfile_search_strategy.rs:21), whichunderstands both the root and
.minimal/layouts. Reusing it makesdetection match the exact path the init writer would target. Any outcome other
than
mfile::Error::NotFound(including a present-but-malformed file) counts as"exists", so we never scaffold over it.
Fix B — never default-YES a scaffold on non-interactive stdin. The prompt is
now gated on
std::io::stdin().is_terminal(). On non-tty stdin we skip theprompt entirely and
bail!with the existing"No {MFILE_NAME} found. Run 'minimal init' to create one."hint instead ofdefault-YES-ing a destructive create.
Where I gated the tty check: at the
ensure_mfile_or_promptcall site, notin the shared
confirm().confirm()has one other caller — therun_init_flow"Continue?" prompt — which legitimately wantsempty-input-defaults-yes and is only reached once we're already committed to a
non-destructive init flow. Changing
confirm()globally would be a wider blastradius for no benefit, so gating only the destructive path is the surgical
choice.
Invariants after the fix
min activate .with non-tty stdin over a project that has a config(root or
.minimal/) → proceeds, config untouched.min activate .with non-tty stdin over a project with no config →errors telling the user to run
minimal init; writes nothing.Test
Added focused, behavior-level tests in the
minimalcrate's existingtestsmodule:
project_has_mfile_detects_dot_minimal_layout— the core regression: a tempdir containing
.minimal/minimal.tomlis detected (so activate returnswithout prompting and never scaffolds over it). This is exactly what the old
naive
join(MFILE_NAME)check missed.project_has_mfile_false_when_absent— a project with no config in eitherlayout reports false, so the caller falls through to the (tty-gated) prompt.
Detection was factored into a small pure
project_has_mfilehelper to make itdirectly testable; the stdin/tty branch is environment-dependent and left to
manual/CI coverage.
Validation
cargo fmt— clean (no reformatting of the change).cargo test -p minimal/cargo clippy -p minimal --all-targets -- -D warningscan't run natively on the macOS dev host: the
minimalcrate transitivelydepends on
procfs(viaminvmd/switch), which is Linux-only. A Linuxcrossbuild compiled the full dependency tree and reached theminimalpackage, failing only in
crates/minimal/build.rs(agit rev-parsefor theversion string returns empty inside the container because this is a git
worktree whose gitdir isn't mounted) — a pre-existing, environment-specific
build-script issue unrelated to this change. Definitive
cargo test -p minimaltests in one crate with no platform-specific surface.
🤖 Generated with Claude Code
Summary by CodeRabbit
minimal initwhen configuration is missing.