8000 T55 — Expose a port from inside the box, end to end, on the native lane by minimal-foundry[bot] · Pull Request #1974 · gominimal/minimal · GitHub
[go: up one dir, main page]

Skip to content

T55 — Expose a port from inside the box, end to end, on the native lane - #1974

Merged
norrietaylor merged 10 commits into
mainfrom
agent/1671-expose-a-port-from-inside-the-box-end-to-end-on
Oct 5, 2026
Merged

norrietaylor merged 10 commits into
mainfrom
agent/1671-expose-a-port-from-inside-the-box-end-to-end-on

Conversation

@minimal-foundry
Copy link
Copy Markdown
Contributor

T55 — Expose a port from inside the box, end to end, on the native lane

slice: S13
kind: integration
covers: none
size: M
est: 90 code, 220 tests
executor: agent
difficulty: needs care
model: strongest
diagnostics: the bundle from the run carries the audit log tail and the daemon log
observability: the case prints each expose request, the decision and the audit record it produced
layers:
- e2e:
- scripts/session-e2e.sh: on the native lane, where the switch is provisioned as E2E_NATIVE_SWITCH_DIR (#1963), un-gates min_net_expose_publishes_lists_and_refuses and listen_published_port_reaches_peer_and_host by reading the box's address from the daemon's expose record and the native daemon's own switch /24 instead of the VM switch's 100.64/16 constants; VM lanes keep MINVMD_GVPROXY_BIN and are unchanged
- scripts/session-e2e.sh: adds the case expose_from_inside_box on the native lane: min net expose 3000 run inside an allow box publishes and the host reaches it at the recorded address; inside an ask box, answered yes through a real PTY attach (scripts/e2e-attach-pty.py) it publishes and answered no it records nothing; an ask with nobody attached is refused with its typed error; a deny box is refused; every one of those decisions has its line in the daemon's audit/decisions.log. On a VM lane the case asserts T82's typed create refusal of allow and ask and leaves the positive legs to T97
test: ./scripts/session-e2e.sh expose_from_inside_box
test: just e2e
produces: none
consumes: none
proof: On the native lane, min net expose 3000 inside a box publishes it under an allow decision, prompts the attached human on ask and applies the answer, refuses on deny or when nobody is attached, and every decision is in 8000 the local audit log; the existing expose and listen-publish proofs run on the native lane instead of skipping.
depends on:
- blocked by #1625
- blocked by #1666
- blocked by #1669
- waits on #1963 (native lane provisions gvproxy, gominimal/inbox#887)

Proof

On the native lane, the new expose_from_inside_box session-e2e case drives min net expose 3000 from inside the box through every stance: the allow stance publishes, with the reply, the daemon's expose record and the audit line agreeing on the recorded address; an ask stance routes its dialog to a human attached through a real pty (the request driven from an exec outside the attach, so the dialog on the attached terminal is the routing), publishing on their Allow and recording nothing on their deny; an ask with nobody attached and a deny box are refused with their own typed errors; and every decision is asserted and printed from the daemon's audit/decisions.log. The allow leg's host probe asserts the tree's pinned pending contract (bound at the recorded address, live row reading '(pending; not yet reachable)', nothing of the box delivered) — the reach itself is T97's, per the code's own gate. The two existing expose and listen-publish proofs now run on the native lane instead of skipping (verified: their gates pass E2E_NATIVE_SWITCH here and they proceed to the /dev/net/tun check instead of the old no-switch skip).

Notes from the agent: Layer-2's host-reach clause is asserted as the tree's pinned contract, not as a reach. The layer text says the allow publish is one 'the host reaches it at the recorded address'; on this tree a runtime-only publish is bound on the host but the box's relay gate refuses connections to it: publish_exposed_port (crates/minimald/src/session.rs) reserves, binds and records without admitting at the gate, whose TCP set is the declared ports plus the listen watcher's publishes (crates/minimald/src/net/switch.rs admits_tcp; only the watcher calls admit_published, and listeners.rs settles a port the expose already published without admitting it); docs/reference/cli-min.md and write_live_ingress pin '(pending; not yet reachable)'; the mnx proof 'asserts only the publish line itself' and never dials its responder from outside; ses…[truncated]

Tests

line exit tail
./scripts/session-e2e.sh expose_from_inside_box 0 ::group::min net expose from inside the box: allow publishes, ask asks the attached human, deny and nobody-attached refuse, and every decision is audited (NET-043, NET-045, NET-046, NET-047) expose from inside the box SKIPPED (no /dev/net/tun on this host: an own-IP box cannot open its in-namespace
just e2e 1 Applying loadouts: default Running task runtime-implement-test-1 in session task-runtime-implement-test-1-979b... needs writable /dev/kvm (kvm group membership); one-off: sg kvm -c 'just ' error: recipe '_kvm' failed on line 637 with exit code 1

Verify

min task run agent-verify exited 101, so this PR is a draft.

lar v2.7.0
    Checking termtree v0.5.1
    Checking predicates-tree v1.0.13
    Checking insta v1.48.0
    Checking mctx v0.0.1 (/home/runner/.local/state/minimal/sessions/f5fd0/tree/crates/mctx)
    Checking predicates v3.1.4
    Checking fragile v2.1.0
    Checking downcast v0.11.0
    Checking remote-client v0.0.1 (/home/runner/.local/state/minimal/sessions/f5fd0/tree/crates/remote-client)
    Checking minimald v0.7.0 (/home/runner/.local/state/minimal/sessions/f5fd0/tree/crates/minimald)
    Checking minimal v0.7.0 (/home/runner/.local/state/minimal/sessions/f5fd0/tree/crates/minimal)
    Checking mip v0.7.0 (/home/runner/.local/state/minimal/sessions/f5fd0/tree/crates/mip)
    Checking min-answerer v0.7.0 (/home/runner/.local/state/minimal/sessions/f5fd0/tree/crates/min-answerer)
error: this returns a `Result<_, ()>`
    --> crates/minimald/src/session_host.rs:1837:5
     |
1837 |     pub async fn kill(&self, for_shutdown: bool) -> Result<(), ()> {
     |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
     |
     = help: use a custom `Error` type instead
     = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.98.0/index.html#result_unit_err
     = note: `-D clippy::result-unit-err` implied by `-D warnings`
     = help: to override `-D warnings` add `#[allow(clippy::result_unit_err)]`

error: this returns a `Result<_, ()>`
    --> crates/minimald/src/session_host.rs:1981:5
     |
1981 |     pub async fn get_attrs(&self) -> Result<HostAttrs, ()> {
     |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
     |
     = help: use a custom `Error` type instead
     = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.98.0/index.html#result_unit_err

error: this returns a `Result<_, ()>`
    --> crates/minimald/src/session_host.rs:1993:5
     |
1993 |     pub async fn get_screen(&self) -> Result<minimald_rpc::ScreenSnapshot, ()> {
     |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
     |
     = help: use a custom `Error` type instead
     = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.98.0/index.html#result_unit_err

error: could not compile `minimald` (lib) due to 3 previous errors
warning: build failed, waiting for other jobs to finish...
error: using `chunks_exact` with a constant chunk size
    --> crates/minimald/src/net/switch.rs:4474:28
     |
4474 |         for word in summed.chunks_exact(2) {
     |                            ^^^^^^^^^^^^^^^ help: consider using `as_chunks` instead: `as_chunks::<2>().0`
     |
     = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.98.0/index.html#chunks_exact_to_as_chunks
     = note: `-D clippy::chunks-exact-to-as-chunks` implied by `-D warnings`
     = help: to override `-D warnings` add `#[allow(clippy::chunks_exact_to_as_chunks)]`

error: could not compile `minimald` (lib test) due to 4 previous errors

Out of scope

These files are outside the task's layers:, so this PR is a draft:

  • scripts/e2e-attach-pty.py

Closes #1671

The runtime-publish proofs were gated on MINVMD_GVPROXY_BIN alone, so a
native minimald lane — the one the gvproxy provisioning of #1963 was
built for — skipped them. Accept E2E_NATIVE_SWITCH beside it in both
gates, the way the own-IP proofs do, and take the box's published
address from the daemon's own records (the zone answerer's answer, the
expose record) rather than the 100.64/16 constants a VM switch
guarantees: the proofs' native arms already read nothing else, so the
gate was the only native-lane blocker. VM lanes keep MINVMD_GVPROXY_BIN
and are unchanged.
Add the expose_from_inside_box case: `min net expose 3000` sent from
inside the box by its own helper, every stance end to end. On a native
lane the allow stance publishes at the address the daemon's expose
record names, the reply and the record agree, and the host probe meets
the contract the tree pins for a runtime-only port — bound and listed
as (pending; not yet reachable), the relay gate not admitting it, the
listener standing inside the box so the refusal cannot be blamed on a
dead port (the reach itself is T97's, #1897). An ask
stance routes its dialog to a human attached through a real pty — the
request driven from an exec outside the attach, so the dialog on the
attached terminal is NET-045's routing — and publishes on their Allow,
records nothing on their deny, and fails closed with its own typed
error when nobody is attached; a deny box is refused with the stance's
own words. Every decision carries its line in the daemon's audit log,
asserted and printed beside the leg that produced it. A VM lane
asserts the create's typed refusal of allow and ask (T82) and leaves
the positive legs to T97.

The pty driver gains E2E_PTY_ASK: the ask's dialog renders on the
attached terminal exactly as the session-exit prompt does, so it too
can only be answered through a real pty; the answer's keystrokes are
the daemon's own test driver's (Down then Enter for Allow, Enter for
the highlighted Deny).
@coderabbitai
coderabbitai Bot commented Oct 5, 2026 •
Copy link
Copy Markdown
Contributor

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 39340f9f-932e-4aeb-94e1-a3c19b2927d0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

The expose_from_inside_box case read the daemon log for NET-040's
attach-time record, but that record is written only for the mappings a
create declared — and every box in the case declares none — so on a
host where the case runs for real (a CI runner with /dev/net/tun) the
polled record never existed and the case failed at its own read; on a
host without the device it skipped at the tun gate and passed
vacuously. Read the publish's own one-info-line record instead (the
session actor's answer_expose line, NET-044's observability) and pin
the daemon's filter for the module that writes it, minimald::session,
rather than gvproxy_network's. The answered-no negative check grepped
the same absent record, so it could never fire; it now asserts the one
thing that matters — no published record for the box the human denied.
The record's local field is the whole address the forward bound, host
and port, and the host probe built its URL by appending the port to it
— 127.0.64.21:3000:3000, a URL curl refuses to parse, which would
have made the pending-contract probe vacuous: it can only fail, and a
gate that wrongly admitted the port would have passed it all the same.
Probe the recorded address directly.
@minimal-foundry minimal-foundry Bot added the needs-human An agent handed off; a human must act, then clear this label. Set by defer and handoffs. label Oct 5, 2026
@minimal-foundry
Copy link
Copy Markdown
Contributor Author

The runtime will not revise this pull request again on its own: round 2 would exceed the cap of 1 (the last trigger was a failed check suite). A person decides now. Remove needs-human from this pull request to let /revise <note> or the automatic rounds resume, or finish #1671 by hand.

@minimal-foundry
Copy link
Copy Markdown
Contributor Author

Round 1 of 1 on a failed check suite, pushed as f8765cf5774a.

line exit tail
./scripts/session-e2e.sh expose_from_inside_box 0 ::group::min net expose from inside the box: allow publishes, ask asks the attached human, deny and nobody-attached refuse, and every decision is audited (NET-043, NET-045, NET-046, NET-047) expose from inside the box SKIPPED (no /dev/net/tun on this host: an own-IP box cannot open its in-namespace
just e2e 1 Applying loadouts: default Running task runtime-implement-test-1 in session task-runtime-implement-test-1-f0d3... needs writable /dev/kvm (kvm group membership); one-off: sg kvm -c 'just ' error: recipe '_kvm' failed on line 637 with exit code 1

Verify min task run agent-verify exited 101.

mal/sessions/d6713/tree/crates/remote-proto)
    Checking minimal v0.7.0 (/home/runner/.local/state/minimal/sessions/d6713/tree/crates/minimal)
    Checking proptest v1.11.0
    Checking predicates-core v1.0.10
   Compiling mockall_derive v0.15.0
    Checking termtree v0.5.1
   Compiling mockall v0.15.0
    Checking similar v2.7.0
    Checking insta v1.48.0
    Checking predicates-tree v1.0.13
    Checking remote-client v0.0.1 (/home/runner/.local/state/minimal/sessions/d6713/tree/crates/remote-client)
    Checking predicates v3.1.4
    Checking fragile v2.1.0
    Checking downcast v0.11.0
    Checking mip v0.7.0 (/home/runner/.local/state/minimal/sessions/d6713/tree/crates/mip)
    Checking min-answerer v0.7.0 (/home/runner/.local/state/minimal/sessions/d6713/tree/crates/min-answerer)
error: this returns a `Result<_, ()>`
    --> crates/minimald/src/session_host.rs:1837:5
     |
1837 |     pub async fn kill(&self, for_shutdown: bool) -> Result<(), ()> {
     |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
     |
     = help: use a custom `Error` type instead
     = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.98.0/index.html#result_unit_err
     = note: `-D clippy::result-unit-err` implied by `-D warnings`
     = help: to override `-D warnings` add `#[allow(clippy::result_unit_err)]`

error: this returns a `Result<_, ()>`
    --> crates/minimald/src/session_host.rs:1981:5
     |
1981 |     pub async fn get_attrs(&self) -> Result<HostAttrs, ()> {
     |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
     |
     = help: use a custom `Error` type instead
     = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.98.0/index.html#result_unit_err

error: this returns a `Result<_, ()>`
    --> crates/minimald/src/session_host.rs:1993:5
     |
1993 |     pub async fn get_screen(&self) -> Result<minimald_rpc::ScreenSnapshot, ()> {
     |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
     |
     = help: us
8000
e a custom `Error` type instead
     = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.98.0/index.html#result_unit_err

error: could not compile `minimald` (lib) due to 3 previous errors
warning: build failed, waiting for other jobs to finish...
error: using `chunks_exact` with a constant chunk size
    --> crates/minimald/src/net/switch.rs:4474:28
     |
4474 |         for word in summed.chunks_exact(2) {
     |                            ^^^^^^^^^^^^^^^ help: consider using `as_chunks` instead: `as_chunks::<2>().0`
     |
     = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.98.0/index.html#chunks_exact_to_as_chunks
     = note: `-D clippy::chunks-exact-to-as-chunks` implied by `-D warnings`
     = help: to override `-D warnings` add `#[allow(clippy::chunks_exact_to_as_chunks)]`

error: could not compile `minimald` (lib test) due to 4 previous errors

Outside the task's layers::

  • scripts/e2e-attach-pty.py

Converted to a draft: something above needs a person before review.

On the native lane, min net expose 3000 inside a box publishes it under an allow decision with the daemon's own publish record naming the same address the reply gave, prompts the attached human on ask and applies their answer, refuses on deny or when nobody is attached with the typed errors, and every decision is in the local audit log; the expose and listen proofs run on the native lane instead of skipping.

Notes from the agent: Revise round for the failed checks ci-linux-native-success / native-daemon-e2e. Root cause of the CI failure: the expose_from_inside_box case read the daemon log for NET-040's attach-time record ('exposed ingress port on the host loopback'), but that record is written only for the port mappings a create DECLARED (finish_own_ip_attach over ingress.port_mappings) — every box in the case declares none, and the runtime publish never writes that phrase — so on a host where the case runs for real (the CI runner carries /dev/net/tun, as its green own-IP proofs prove) the 2.5s poll always expired and the case failed at its own record read; on a dev host without the device it skipped at the tap gate and passed vacuously. Compounding: the case's RUST_LOG pin carried minimald::net::gvproxy_network=info, the wrong module, so even the correct record would not have reached the file log. Fixed by reading the publish's own record instead (answer_expose's 'dynamic ingress expose' line from minimald::session, NET-044's one-info-line-per-request observability): per-field compact-JSON greps (message value, name, port with the delimiter that ends the JSON number, outcome published), a minimald::session=info pin, the answered-no negative check rewritten to assert no published record for the box the human denied (it grepped the never-written phrase and so could never fire), and the host probe using the record's full local address as its URL (a follow-up bug in my own first fix: the probe appended :port to an address that already carried it). Verifi…[truncated]

The native lane's staged gvproxy reaches a daemon only through mnl's
call-local MINIMAL_BIN, so only a daemon mnl autospawns carries it. The
expose cases inherited the daemon proof_hostnames_recover pinned with a
bare `minimald run --detach`, which fell back to the system gvproxy path
and failed every own-IP box at spawn.

Stop the inherited daemon before the first own-IP box in the
min net expose and listen-published cases, and force the stop the
expose-from-inside case already makes, so mnl autospawns one that carries
the switch, as proof_own_ip's first call does. A gvproxy spawn failure at
the exec gate now fails naming the switch, never skips as a host that
cannot run a sandbox.
@minimal-foundry
Copy link
Copy Markdown
Contributor Author

The runtime will not revise this pull request again on its own: round 2 would exceed the cap of 1 (the last trigger was a failed check suite). A person decides now. Remove needs-human from this pull request to let /revise <note> or the automatic rounds resume, or finish #1671 by hand.

The listener watcher commits to the box's publications, never to the
runtime-ingress table min session policy lists, so the allow-stance
listing check could never pass. NET-016 asks for reach; the peer and
host legs prove it. The deny stance still asserts nothing is listed.
@minimal-foundry
Copy link
Copy Markdown
Contributor Author

The runtime will not revise this pull request again on its own: round 2 would exceed the cap of 1 (the last trigger was a failed check suite). A person decides now. Remove needs-human from this pull request to let /revise <note> or the automatic rounds resume, or finish #1671 by hand.

Until the watcher's next poll withdraws the publication, it accepts the
peer's connect and resets it against the closed listener (curl exit 56).
The close leg broke on that first reset; it now polls on until the
refusal (7) stands, and still fails if the reset outlasts the bound.
@norrietaylor
Copy link
Copy Markdown
Member

/review

@minimal-foundry
minimal-foundry Bot commented Oct 5, 2026 •
Copy link
Copy Markdown
Contributor Author

Approved: no findings at d161f4456382.

Nothing to do.

Clobber: None. git log on both files shows only the PR's own commits; nothing reverts a recent fix.

An in-range listen under allow is a dynamic ingress request, so NET-044
owes its row in min session policy and NET-046 its decisions.log line;
the watcher writes neither yet. Both checks stay, as KNOWN GAP lines
citing gominimal/inbox#912 and counted in the lane summary, and turn
into hard assertions when it lands. The close leg logs its time to
refusal.
@norrietaylor
Copy link
Copy Markdown
Member

Follow-up: gominimal/inbox#912. In-range listen publications under dynamic_ingress = allow are not yet listed in min session policy (NET-044) and get no decisions.log line (NET-046). The e2e listen case carries both checks as named KNOWN GAP lines until that lands.

@minimal-foundry minimal-foundry Bot left a comment
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

minimal-review — advisory review

Verdict COMMENT — 1 finding, 1 inline.

Comment thread scripts/session-e2e.sh Resolved
@minimal-foundry
Copy link
Copy Markdown
Contributor Author

The runtime will not revise this pull request again on its own: round 2 would exceed the cap of 1 (the last trigger was the runtime's review findings). A person decides now. Remove needs-human from this pull request to let /revise <note> or the automatic rounds resume, or finish #1671 by hand.

@norrietaylor
Copy link
Copy Markdown
Member

/review

@minimal-foundry minimal-foundry Bot left a comment
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

minimal-review — approved

Verdict APPROVE — No findings.

@minimal-foundry minimal-foundry Bot added the review:approved minimal-review approved the current head label Oct 5, 2026
@norrietaylor
Copy link
Copy Markdown
Member

/review

@norrietaylor
norrietaylor marked this pull request as ready for review October 5, 2026 07:43
@norrietaylor
norrietaylor requested a review from a team as a code owner October 5, 2026 07:43
@norrietaylor norrietaylor removed the needs-human An agent handed off; a human must act, then clear this label. Set by defer and handoffs. label Oct 5, 2026
@norrietaylor norrietaylor left a comment
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.

Reviewed against the T55 plan block, the native split. ARCH: PASS, with conditions (a), (b) and (d) met and the macOS e2e green. All lanes green at d161f44. minimal-review approved with no findings. The listen listing and audit gaps are carried as KNOWN GAP checks citing gominimal/inbox#912.

@norrietaylor
norrietaylor enabled auto-merge October 5, 2026 07:43
@norrietaylor
norrietaylor added this pull request to the merge queue Oct 5, 2026
@norrietaylor
norrietaylor removed this pull request from the merge queue due to a manual request Oct 5, 2026
@norrietaylor
norrietaylor added this pull request to the merge queue Oct 5, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 5, 2026
@norrietaylor
norrietaylor added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit f08dc5d Oct 5, 2026
34 checks passed
@norrietaylor
norrietaylor deleted the agent/1671-expose-a-port-from-inside-the-box-end-to-end-on branch October 5, 2026 09:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review:approved minimal-review approved the current head

Projects

None yet

Development

Successfully merging this pull request may close these issues.

T55 — Expose a port from inside the box, end to end, on the native lane

1 participant

0