Skip to content

test: prove the in-sandbox min add path via interactive PTY attach on every lane - #760

Merged
norrietaylor merged 3 commits into
mainfrom
test/session-e2e-sandbox
Jul 15, 2026
Merged

test: prove the in-sandbox min add path via interactive PTY attach on every lane#760
norrietaylor merged 3 commits into
mainfrom
test/session-e2e-sandbox

Conversation

@norrietaylor

@norrietaylor norrietaylor commented Jul 15, 2026

Copy link
Copy Markdown
Member

What

Deepens the shared session e2e (scripts/session-e2e.sh) so that, on every
deployment lane (native DM2, KVM DM3, macOS DM1), it forks a real session
sandbox and proves the in-sandbox min helper end to end — not just the session
lifecycle. Also fixes the CI gap that prevented the VM lanes from running an
in-guest session at all, and improves git-command error reporting.

Three commits.

test: exercise the session sandbox via interactive attach

Attaches an interactive shell to a live session (which forks a real hakoniwa
sandbox — on a VM lane, inside the guest over the vsock bridge) and proves
min add: a genuinely-absent tool (jq — the shell stack composes base +
curl, and jq is pulled in by neither) is recorded absent, min added, then
run; 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), not a pipe: it pumps the
command stream, then answers 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 then be delisted —
doubling as the interactive-teardown proof. Output is matched with a bash glob,
not printf | grep -q: min add's progress-bar flood can make grep -q
short-circuit and SIGPIPE the feeding printf, which under pipefail becomes a
false negative.

Host-side seed: since #748
min activate uploads the project into the session, so the e2e seeds a small,
pinned minimal.toml (the repo's [upstream] verbatim, incl.
locked_commit, plus a light shell stack) in a dir it owns and cleans
(rm -rf first — a persistent/self-hosted runner can carry a stale unpinned
leftover that the graph loader rejects). This also satisfies the
#758 non-interactive min activate pre-flight. Every seed is removed on teardown.

ci: provision gvproxy for the KVM/macOS session-e2e lanes

The VM lanes boot a microVM whose guest daemon clones the upstream pkgs repo
during session mint — which needs the guest's egress (NAT + DNS), provided by
the gvproxy switch minvmd spawns. Neither VM lane provisioned the gvproxy
binary, and its resolution is best-effort (never errors), so minvmd booted the
VM switchless: the guest overlay had no other end and every egress died with
Could not resolve host: github.com. It looks like DNS but is dead L3 egress —
raw TCP to literal public IPs returns EHOSTUNREACH, and even the gateway is
silent.

Fetches the pinned gvproxy (scripts/fetch-gvproxy.sh +
vendor/gvproxy/gvproxy.lock, already used by the native
minimald-root-integration job) and points minvmd at it via
MINVMD_GVPROXY_BIN on each VM lane's session-e2e step. The macOS lane
auto-selects the gvproxy-darwin asset. Native lanes never needed it (minimald
runs on the host with real internet; no overlay).

fix(checkouts): report exit status on git command failure

A failed git command surfaced only its stderr, which for a signal-killed process
can be a misleadingly benign line (e.g. a "templates not found" warning that
masks that the process was killed). Includes the rendered ExitStatus (exit code
or signal) in GitCommandFailed and its mctx wrapper.

Validated

All three e2e lanes green — native, KVM, and macOS: the in-sandbox min add jq +
run + interactive Delete-teardown round-trips on each. Rebased onto main
(post-#748); cargo/clippy
clean via cross; shellcheck/bash -n/py_compile/actionlint clean.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Git command errors now display the process termination status alongside the failed command and error output.
    • Added interactive end-to-end coverage for installing and running tools inside an attached session.
  • Bug Fixes
    • Improved session teardown verification when users choose to delete a session.
  • Tests
    • Enhanced Linux and macOS session testing with pinned networking support.
    • Added validation for project setup, tool installation, session lifecycle, and restart behavior.

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e391f379-169f-42f8-bb13-fb68bc94eda7

📥 Commits

Reviewing files that changed from the base of the PR and between c2d4a74 and 0b73aaa.

📒 Files selected for processing (4)
  • .github/workflows/ci-linux-kvm.yml
  • .github/workflows/ci-macos.yml
  • scripts/e2e-attach-pty.py
  • scripts/session-e2e.sh
📝 Walkthrough

Walkthrough

The change adds process termination status to git command errors and updates their formatting. It also expands session E2E coverage with PTY-driven attach teardown, temporary project seeding, non-baseline tool checks, cleanup assertions, and gvproxy provisioning in Linux KVM and macOS workflows.

Changes

Git status reporting

Layer / File(s) Summary
Git failure status propagation
crates/checkouts/src/error.rs, crates/checkouts/src/repo.rs, crates/mctx/src/error.rs
Git command failures now capture process status and include it in checkout and top-level error messages.

Session E2E validation

Layer / File(s) Summary
PTY attach driver
scripts/e2e-attach-pty.py
The new helper drives min attach through a PTY, selects interactive deletion, captures output, and reports process status.
Sandbox seeding and lifecycle proof
scripts/session-e2e.sh
The harness seeds pinned project configuration, checks tool installation during attach, cleans temporary files, and verifies session removal after interactive teardown.
gvproxy CI wiring
.github/workflows/ci-linux-kvm.yml, .github/workflows/ci-macos.yml
Both workflows fetch pinned gvproxy binaries and pass their paths to the session E2E step.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CIWorkflow
  participant SessionE2E
  participant PTYHelper
  participant MinAttach
  CIWorkflow->>SessionE2E: Run session E2E with MINVMD_GVPROXY_BIN
  SessionE2E->>PTYHelper: Start interactive attach proof
  PTYHelper->>MinAttach: Execute attach through PTY
  MinAttach-->>PTYHelper: Return tool output and exit prompt
  PTYHelper->>MinAttach: Select Delete
  PTYHelper-->>SessionE2E: Return captured output and status
  SessionE2E-->>CIWorkflow: Report lifecycle assertions
Loading

Suggested reviewers: twitchyliquid64

Poem

A rabbit hops through shells so bright,
Adds jq and checks the prompt just right.
Git errors wear status with pride,
gvproxy joins the network ride.
Tests clean the burrow by moonlight.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: an interactive PTY-based in-sandbox min add test across all lanes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@norrietaylor
norrietaylor marked this pull request as ready for review July 15, 2026 02:08
@norrietaylor
norrietaylor force-pushed the test/session-e2e-sandbox branch 2 times, most recently from 9735bae to 6cc1805 Compare July 15, 2026 02:17
@norrietaylor norrietaylor changed the title test: exercise the session sandbox in session-e2e (min add + min run) fix(minimald): non-interactive session-exit + exercise the sandbox in session-e2e Jul 15, 2026
@norrietaylor
norrietaylor marked this pull request as draft July 15, 2026 02:18
@norrietaylor
norrietaylor marked this pull request as ready for review July 15, 2026 02:27
@norrietaylor
norrietaylor enabled auto-merge (squash) July 15, 2026 02:27
@norrietaylor
norrietaylor disabled auto-merge July 15, 2026 02:28
@norrietaylor
norrietaylor force-pushed the test/session-e2e-sandbox branch from 6cc1805 to 1761725 Compare July 15, 2026 03:57

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/minimald/src/session_host.rs`:
- Around line 467-489: Wrap the async_dialog::Select interaction in the
interactive exit flow with the existing detach-timeout mechanism, treating
timeout expiry as a detach while preserving the current selection, cancellation,
and error handling behavior. Update the match around interact in the session
exit logic so an expired prompt follows the same path as Ok(Selection::At(0)) or
Ok(Selection::Cancelled).

In `@scripts/session-e2e.sh`:
- Around line 226-240: Update the command stream assembled in the exec_out block
so the TOOL_ABSENT_BEFORE marker is constructed dynamically at execution time
rather than appearing literally in the echoed input. Keep the existing grep
assertion unchanged, ensuring only actual command output can satisfy the absence
check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 599010d2-1986-49dc-95dc-7320626ba61c

📥 Commits

Reviewing files that changed from the base of the PR and between 197fa1e and 1761725.

📒 Files selected for processing (8)
  • crates/checkouts/src/error.rs
  • crates/checkouts/src/repo.rs
  • crates/mctx/src/error.rs
  • crates/minimal/src/lib.rs
  • crates/minimald/src/lib.rs
  • crates/minimald/src/session.rs
  • crates/minimald/src/session_host.rs
  • scripts/session-e2e.sh

Comment thread crates/minimald/src/session_host.rs Outdated
Comment thread scripts/session-e2e.sh Outdated
@norrietaylor
norrietaylor force-pushed the test/session-e2e-sandbox branch 2 times, most recently from a743cc7 to d125083 Compare July 15, 2026 04:20
Comment thread crates/minimal/src/lib.rs Outdated
"UserKnownHostsFile=/dev/null",
]);
ssh.env("MINIMAL_SESSION_ID", record.id.to_string())
.env("MINIMAL_INTERACTIVE", if interactive { "1" } else { "0" })

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The canonical way to signal interactivity is whether the ssh process requests a pty or not, if i recall correctly its -tvs -T.

Comment thread crates/minimald/src/session.rs Outdated
// Whether the attaching client's own terminal is interactive; drives the
// session-exit prompt (interactive) vs. delete-on-exit (non-interactive)
// decision in the binding.
let interactive = config.interactive();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The more I think about this, I don't think we should do this. The intent of a session is always interactive, its things like tasks and such that are intended for the more deterministic / exec-like execution. Lets use expect and/or pump keystrokes?

@norrietaylor norrietaylor Jul 15, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, I could see this both ways.

While I agree that our intent of a session as spec'ed was interactive and non-deterministic, generally I try to avoid adding unnecessary constraints.

Additionally, there could be a use case here. non-interactive and non-determinsitic are not mutually exclusive. I could see passing a session to an agent running in prompt mode without a PTY.

I'm going to leave this one in draft mode while I figure out some deeper issues around why the DNS isn't resolving inside a github runner.

Comment thread scripts/session-e2e.sh Outdated
# baseline (base/coreutils/socat), then run it: the tool being absent
# before and runnable after proves `min add` reached the daemon over the
# in-sandbox `/run/minenv_sock` relay and hardlinked the package into the
# live rootfs. `min add` with no flag defaults to `--session` (this session

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did we implement --session ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so. I have no idea where this came from. My clanker is getting testy with this one.

Comment thread scripts/session-e2e.sh Outdated
# `--command`) opens a PTY-backed shell; we pipe it a command stream ending
# in `exit`. When the shell exits, the daemon's session-exit prompt would
# historically block waiting for an arrow-key answer. The `min` client now
# reports whether its own context is a tty (MINIMAL_INTERACTIVE); a

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets just use expect or pump keystrokes instead of this MINIMAL_INTERACTIVE thing.

@norrietaylor
norrietaylor marked this pull request as draft July 15, 2026 07:12
@norrietaylor
norrietaylor force-pushed the test/session-e2e-sandbox branch 4 times, most recently from 92a237c to 27d3394 Compare July 15, 2026 15:30
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>
@norrietaylor
norrietaylor force-pushed the test/session-e2e-sandbox branch 2 times, most recently from 50e1f24 to c2d4a74 Compare July 15, 2026 18:30
@norrietaylor norrietaylor changed the title fix(minimald): non-interactive session-exit + exercise the sandbox in session-e2e fix(minimald): exercise the sandbox in session-e2e Jul 15, 2026
@norrietaylor norrietaylor changed the title fix(minimald): exercise the sandbox in session-e2e test: prove the in-sandbox min add path via interactive PTY attach on every lane Jul 15, 2026
@norrietaylor
norrietaylor marked this pull request as ready for review July 15, 2026 19:46

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
.github/workflows/ci-macos.yml (1)

331-359: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Move gvproxy provisioning out of the frozen workflow layer.

Make the shared session E2E script provision and export gvproxy for VM lanes, then revert these workflow edits.

As per coding guidelines, “Do not edit files under .github/workflows/; the CI layer is frozen and CODEOWNER-gated,” and extend coverage through scripts/ or justfile. Based on learnings, this test expansion belongs in convention-discovered scripts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci-macos.yml around lines 331 - 359, Move gvproxy
provisioning from the macOS workflow step into the shared session E2E
implementation, updating the relevant script or justfile target to fetch and
export the VM-lane gvproxy path before minvmd runs. Remove the workflow-specific
provisioning and related setup changes, while preserving the existing session
E2E behavior and using the established MINVMD_GVPROXY_BIN configuration.

Sources: Coding guidelines, Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ci-linux-kvm.yml:
- Around line 338-357: Remove the gvproxy provisioning step and related
MINVMD_GVPROXY_BIN workflow configuration from the KVM lane in the workflow.
Update scripts/session-e2e.sh to provision a temporary gvproxy when
MINVMD_GVPROXY_BIN is unset, while preserving externally supplied binaries and
the existing session-e2e flow.

In `@scripts/session-e2e.sh`:
- Around line 91-100: Update the VM seed setup in the E2E project-directory
branch to create a unique temporary directory using mktemp instead of the fixed
/tmp/mnl-e2e-project path. Preserve assigning the generated directory to
PROJECT_DIR and remove only that run’s directory during setup, preventing
concurrent VM runs from deleting each other’s seed data.

---

Outside diff comments:
In @.github/workflows/ci-macos.yml:
- Around line 331-359: Move gvproxy provisioning from the macOS workflow step
into the shared session E2E implementation, updating the relevant script or
justfile target to fetch and export the VM-lane gvproxy path before minvmd runs.
Remove the workflow-specific provisioning and related setup changes, while
preserving the existing session E2E behavior and using the established
MINVMD_GVPROXY_BIN configuration.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cda574d2-4331-48d9-82e4-354bb57e6780

📥 Commits

Reviewing files that changed from the base of the PR and between 1761725 and c2d4a74.

📒 Files selected for processing (7)
  • .github/workflows/ci-linux-kvm.yml
  • .github/workflows/ci-macos.yml
  • crates/checkouts/src/error.rs
  • crates/checkouts/src/repo.rs
  • crates/mctx/src/error.rs
  • scripts/e2e-attach-pty.py
  • scripts/session-e2e.sh
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/checkouts/src/error.rs
  • crates/mctx/src/error.rs

Comment thread .github/workflows/ci-linux-kvm.yml
Comment thread scripts/session-e2e.sh
norrietaylor and others added 2 commits July 15, 2026 13:07
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 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>
@norrietaylor
norrietaylor force-pushed the test/session-e2e-sandbox branch from c2d4a74 to 0b73aaa Compare July 15, 2026 20:07
@norrietaylor
norrietaylor enabled auto-merge (squash) July 15, 2026 20:12
@norrietaylor
norrietaylor merged commit e8a71a0 into main Jul 15, 2026
27 checks passed
@norrietaylor
norrietaylor deleted the test/session-e2e-sandbox branch July 15, 2026 20:29
norrietaylor added a commit that referenced this pull request Jul 16, 2026
…ox proof

The native-e2e lane dropped its userns sysctl step on the rationale that
session-e2e.sh forks no sandbox — true when written, falsified by the
in-sandbox `min add` proof (#760) merged since: on a restricted runner
the proof's sandbox dies at uid_map and surfaces as the misleading
'jq was already present' error (the absent-marker never prints).

Load the shipped profile from the script itself, attached to the
minimald the run will spawn — dogfooding the documented remediation on
CI and on any restricted host running the e2e locally. Native Linux
only: VM lanes create their userns in-guest as root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
norrietaylor added a commit that referenced this pull request Jul 16, 2026
…ox proof

The native-e2e lane dropped its userns sysctl step on the rationale that
session-e2e.sh forks no sandbox — true when written, falsified by the
in-sandbox `min add` proof (#760) merged since: on a restricted runner
the proof's sandbox dies at uid_map and surfaces as the misleading
'jq was already present' error (the absent-marker never prints).

Load the shipped profile from the script itself, attached to the
minimald the run will spawn — dogfooding the documented remediation on
CI and on any restricted host running the e2e locally. Native Linux
only: VM lanes create their userns in-guest as root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
norrietaylor added a commit that referenced this pull request Jul 16, 2026
* feat(minimald): ship an AppArmor profile granting minimald userns

Ubuntu 24.04+ defaults kernel.apparmor_restrict_unprivileged_userns=1,
which bars unconfined programs from creating unprivileged user
namespaces. Every minimald session is one (hakoniwa forks and the child
writes /proc/self/uid_map), so on a stock 24.04 host no session can
start: the child dies with EPERM before it runs, surfacing only as an
opaque uid_map DIAG in the daemon log.

Ship the accommodation Ubuntu intends, the same shape the distro ships
for rootlesskit, runc and podman: an unconfined-mode profile whose sole
purpose is to give minimald a named label carrying the `userns`
permission. It confines nothing, and unlike setting the sysctl to 0 it
does not hand user namespaces back to every other program on the host.

minimald has no single install path (the installer puts it in
${MINIMAL_BIN:-~/.local/bin}, a package would use /usr/bin), and AppArmor
attaches profiles by executable path, so the attachment set lives in a
tunable; --path extends it for dev builds via tunables/minimald.d.

Verified on Ubuntu with the restriction on: without the profile an
interactive attach yields no sandbox shell; with it loaded the daemon
runs as `minimald (unconfined)` and the shell comes up in its own user
namespace. Unconfined processes remain unable to unshare.

Refs: #610

* fix(scripts): assign SRC_DIR before readonly in the apparmor installer

shellcheck SC2155: `readonly VAR=$(cmd)` masks the command's exit
status, so a missing packaging/apparmor dir would go unnoticed instead
of failing the script.

* ci: drop dead userns sysctl from the native e2e job

native-daemon-e2e lifted Ubuntu's unprivileged-userns restriction
(`sysctl ...=0`) on the theory that the session sandbox needs it. It does
not: session-e2e.sh drives activate/list/destroy/stop only and never
forks a session sandbox, so the job needs no user namespace — verified by
running the e2e green with the restriction left on. Remove the step and
leave a comment so it is not re-added; correct the job header, which
wrongly claimed the e2e execs inside the sandbox (it does not — that path
is proven in Rust, per session-e2e.sh).

Sandbox-forking coverage that genuinely needs userns stays put: the
workspace tests and the minimald-root-integration job (whose own sysctl
step is unaffected — its userns comes from the cargo test binary, not
minimald).

Refs: #610

* feat(installer): ship and advise the minimald AppArmor profile

The curl|sh installer had no path to the AppArmor profile that lets
minimald create the unprivileged user namespace every session sandbox
needs: the profile and its loader lived only in the source tree, so
`sudo scripts/install-apparmor-profile.sh` was a dead reference for
anyone who installed via curl|sh. On stock Ubuntu 24.04+ that left every
session dying at uid_map with an opaque EPERM and no in-product hint.

Plumb it in without ever elevating from the installer:

- stage-release.sh ships packaging/apparmor/{minimald,tunables/minimald}
  and install-apparmor-profile.sh as data-prefix components, installed to
  ~/.local/share/minimal/apparmor/.
- install.sh prints a gated, Linux-only advisory when it detects
  apparmor_restrict_unprivileged_userns=1, pointing at the shipped loader
  (sudo bash ...). Advice only; it never runs sudo.
- On --uninstall, offer to remove the system profile too: prompt on a
  terminal, advise the root command otherwise, run before the record walk
  deletes the loader. Never touches /etc/apparmor.d unattended.
- install-apparmor-profile.sh resolves its source dir from either the
  checkout layout or the shipped-flat layout.
- The install loop now creates each component's parent dir, so a
  component whose subpath nests directories installs correctly.

install_test.sh covers placement, the advisory on/off, darwin-skip, and
the uninstall advisory (no elevation, loader removed).

Refs: #610

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(installer): skip the userns advisory when the profile is installed

The install-time advisory checked only the sysctl and the shipped
loader, so a reinstall on an already-remediated host still claimed the
sandbox "cannot start" — the profile is loaded and sessions work, yet
the sysctl stays 1. Gate the advisory on the absence of the installed
profile at /etc/apparmor.d/minimald (via the same MINIMAL_OVERRIDE_-
APPARMOR_DIR seam the uninstall path uses), so it fires only on a host
that is restricted and not yet remediated.

Also show the loader's output in the curl-installed docs example, so the
command block carries output like its sibling (satisfies markdownlint
MD014).

Both raised by CodeRabbit on #740.

* feat(sandbox2,minimald): preflight the userns restriction at daemon startup

Every session sandbox starts by unsharing an unprivileged user
namespace, forked from the daemon with no exec in between. On a host
that refuses it (stock Ubuntu 24.04+ with an unconfined daemon, or user
namespaces disabled outright) the denial previously surfaced only when
the first attach died writing /proc/self/uid_map, with nothing useful
in the daemon log.

sandbox2 gains user_namespaces_restriction() — a read-only probe of the
userns quota, the apparmor_restrict_unprivileged_userns sysctl, euid,
and the process's own AppArmor label, mirroring the crate's existing
net-namespace probe — and minimald warns at startup naming the
restriction and the exact fix. Root (the in-guest microVM pid-1) and
confined daemons stay silent, so the common case logs nothing.

Refs: #610

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(installer): point the userns advisory at docs.minimal.dev

The reference docs ship to https://docs.minimal.dev/ with this release,
so the advisory's details link should point there rather than at a
GitHub blob URL. The build-specs link to minimal.ncl stays on GitHub:
it targets source, not a hosted doc page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(minimald,paths): derive the advisory's loader path and attach the running binary

The startup warning hardcoded ~/.local/share for the shipped loader, so
the advised command was wrong whenever XDG_DATA_HOME was set, and it
omitted --path, so a daemon outside the tunable's stock attachment set
(a custom MINIMAL_BIN, a dev build) was told to load a profile that
would never attach to it. paths gains minimal_data_dir(), resolving
exactly like the installer's data prefix, and the fix string now carries
--path <current_exe>.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(scripts): preserve arguments in the loader's sudo hint

The parse loop consumes $@ before the root check, so the hinted
`sudo $0 $*` always printed bare — a copy-pasted retry of
--uninstall would install, and one of --path would skip the attachment.
Capture the original argv up front and print that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(installer): cover custom MINIMAL_BIN in advisory; stop the daemon only for bin/lib swaps

Two review findings against the advisory and the R5.5 stop:

- The stock tunable attaches only /usr/bin, /usr/local/bin, and
  ~/.local/bin, so for a custom MINIMAL_BIN the advised command now
  carries --path "$bindir/minimald", and a loaded profile counts as
  remediation only when the tunables actually name that binary —
  previously the advisory suppressed itself while sessions stayed
  broken.
- stop_running_daemon now fires only for bin/lib component swaps:
  replacing a data file (the shipped apparmor text) must not kill live
  sessions to swap a file the running daemon does not serve from.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(release): stage repo files into the workdir so --dry-run touches nothing

stage_repo_file wrote the three apparmor files into ARTIFACTS_DIR before
the dry-run branch, mutating a directory the caller owns (and dying
outright when it is read-only). Stage into the ephemeral workdir and let
the manifest loop prefer staged files over same-named artifacts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(scripts): load the minimald AppArmor profile before the e2e sandbox proof

The native-e2e lane dropped its userns sysctl step on the rationale that
session-e2e.sh forks no sandbox — true when written, falsified by the
in-sandbox `min add` proof (#760) merged since: on a restricted runner
the proof's sandbox dies at uid_map and surfaces as the misleading
'jq was already present' error (the absent-marker never prints).

Load the shipped profile from the script itself, attached to the
minimald the run will spawn — dogfooding the documented remediation on
CI and on any restricted host running the e2e locally. Native Linux
only: VM lanes create their userns in-guest as root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
norrietaylor added a commit that referenced this pull request Jul 17, 2026
#760 deepened session-e2e.sh with the in-sandbox min add proof, whose
session mint clones the pkgs upstream inside the guest, and added
fetch-gvproxy + MINVMD_GVPROXY_BIN to the KVM and macOS lanes — but not
to nightly-tests' session-e2e-soak. With no gvproxy staged, minvmd
boots the VM switchless (resolution is best-effort and never errors),
the guest has no egress or DNS, and every one of the soak's 10
iterations dies with 'Could not resolve host: github.com' (first seen
on the Jul 16 scheduled run, the first after #760 merged). Stage the
pinned gvproxy and point MINVMD_GVPROXY_BIN at it, exactly as the KVM
lane does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
twitchyliquid64 pushed a commit that referenced this pull request Jul 17, 2026
…efore pushing unstable (#759)

* ci(release): smoke-test the shipped artifacts before publish

Prove the just-built RELEASE binaries actually run before publishing any of
them. Three new jobs download the uploaded artifacts by name, put the SHIPPED
binaries on PATH (never a fresh target/ build), and run the ONE shared session
e2e (scripts/session-e2e.sh — the same proof the CI lanes run):

- smoke-linux-amd64 (ubuntu): DM2 native minimald — the static-musl
  min/minimald boot a host daemon and pass the session e2e.
- smoke-linux-kvm (KVM-capable ubuntu): DM3 — boot a real microVM from the
  shipped native-glibc minvmd + shipped guest kernel/rootfs/initramfs +
  pinned gvproxy; libkrun is materialized from the upstream package (not
  shipped on Linux) via the shipped static mip.
- smoke-macos (self-hosted mini): DM1 — lay the SIGNED min/minvmd/libkrun.1.dylib/
  gvproxy + arm64 guest payload into the installer layout (libkrun.1.dylib a
  lib/ sibling of bin/, resolved via @loader_path/../lib) and boot the microVM.

A new skip-tolerant smoke-success aggregator (if: always(), fails only on
failure/cancellation) is added to the release job's needs, so a smoke failure
blocks every publish step while a skipped smoke-macos (RUN_MACOS_CI kill-switch)
reads as pass. Smoke has no !inputs.dry_run guard: a dry run builds, signs,
assembles, and SMOKES, then publishes nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ci): grant nightly the checks scope its release call needs

release.yml's verify-ci gate (#757) requests checks: read at the job
level. When release.yml runs as a workflow_call from nightly.yml, it
cannot hold any scope the caller's permissions block doesn't grant, and
nightly.yml granted only contents: write and id-token: write — so every
scheduled run since #757 merged died at workflow compile time with
startup_failure (Jul 15 and Jul 16), cutting no nightly release. Manual
release.yml dispatches kept working because a directly-dispatched
workflow uses its own permissions, which is why this went unnoticed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(nightly): smoke the shipped artifacts before advancing the channel

Move the three smoke jobs and their skip-tolerant aggregator from
release.yml into nightly.yml. The workflow_call jobs are part of the
caller's run, so each smoke job still downloads the just-built artifacts
by name and runs the one shared session e2e against the shipped bytes;
what changes is what the smoke gates. In release.yml it gated the
publish step of every release; here it gates promote-nightly, so a
failed smoke leaves the nightly channel pointing at yesterday's build
instead of blocking the release machinery itself. On a no-op night
(HEAD already staged) release and the smokes both skip and the channel
flip proceeds against bytes that were smoked the night they were cut.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): point the smoke VMs at the shipped gvproxy explicitly

minvmd never resolves gvproxy from PATH — resolve_gvproxy_path() honours
only MINVMD_GVPROXY_BIN, the installer bin dir ($MINIMAL_BIN /
~/.local/bin), and /usr/lib/minimal/bin. Renaming the shipped binary
onto PATH therefore left both VM smokes switchless: gvproxy resolution
is best-effort, so minvmd boots without guest egress and the in-guest
pkgs clone during session mint dies with 'Could not resolve host' —
the exact 10/10 failure the Jul 16 session-e2e-soak hit for the same
reason. Set MINVMD_GVPROXY_BIN on both session-e2e steps, as the KVM
and macOS lanes do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): stage gvproxy for the session-e2e soak

#760 deepened session-e2e.sh with the in-sandbox min add proof, whose
session mint clones the pkgs upstream inside the guest, and added
fetch-gvproxy + MINVMD_GVPROXY_BIN to the KVM and macOS lanes — but not
to nightly-tests' session-e2e-soak. With no gvproxy staged, minvmd
boots the VM switchless (resolution is best-effort and never errors),
the guest has no egress or DNS, and every one of the soak's 10
iterations dies with 'Could not resolve host: github.com' (first seen
on the Jul 16 scheduled run, the first after #760 merged). Stage the
pinned gvproxy and point MINVMD_GVPROXY_BIN at it, exactly as the KVM
lane does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

2 participants