From d563099c857ae895ddf461699904d4ae56632447 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 10:12:56 +0000 Subject: [PATCH 01/13] feat(native): add interactive exec with retained session cleanup --- .github/workflows/ci.yml | 9 + docs/diagrams/interactive-session.mmd | 35 + docs/diagrams/interactive-session.svg | 66 ++ docs/guides/native-candidate.md | 49 +- docs/reference/native-authored-commands.md | 53 +- docs/reference/v5-model-coverage.md | 2 + .../guest/environment-launcher.zig | 108 +++ packages/runtime-core/src/error.rs | 12 + .../runtime-core/src/interactive_exec_cli.rs | 126 +++ packages/runtime-core/src/main.rs | 16 + packages/runtime-core/src/provider/engine.rs | 14 + .../src/provider/engine/interactive_exec.rs | 401 ++++++++++ .../src/provider/engine/relay_exec.rs | 34 +- .../src/provider/environment_recovery.rs | 2 +- .../provider/graph/foreground/transport.rs | 2 +- .../src/provider/graph/interactive_exec.rs | 207 +++++ .../runtime-core/src/provider/graph/mod.rs | 3 + .../src/provider/graph/native/foreground.rs | 155 +++- .../src/provider/graph/native/runtime.rs | 4 + .../src/provider/graph/native/runtime/exec.rs | 70 ++ .../src/provider/graph/service_exec.rs | 124 +++ .../src/provider/interactive_handoff.rs | 331 ++++++++ .../src/provider/interactive_session.rs | 719 ++++++++++++++++++ .../src/provider/interactive_terminal.rs | 593 +++++++++++++++ .../runtime-core/src/provider/lifecycle.rs | 2 + .../src/provider/managed_environment.rs | 18 + packages/runtime-core/src/provider/mod.rs | 6 + .../src/provider/private_input.rs | 17 +- scripts/lib/tla-runtime-models.ts | 60 ++ src/backends/native-authored-project-exec.ts | 47 ++ src/backends/native-interactive-exec.ts | 378 +++++++++ src/backends/native-project-exec.ts | 57 +- src/lib/native-authored-command.ts | 6 +- src/lib/tty-process-group.ts | 7 +- tests/fixtures/environment-launcher-tty.py | 98 +++ tests/fixtures/native-interactive-frontend.py | 73 ++ tests/fixtures/native-interactive-frontend.ts | 19 + tests/fixtures/native-interactive-runtime.py | 44 ++ tests/fixtures/native-interactive-terminal.py | 94 +++ .../tla/interactive-envelope/COVERAGE.md | 21 + .../tla/interactive-envelope/Envelope.tla | 41 + .../tla/interactive-envelope/negative.cfg | 4 + .../tla/interactive-envelope/positive.cfg | 4 + .../tla/interactive-session/COVERAGE.md | 28 + .../tla/interactive-session/Session.tla | 57 ++ .../interactive-session/eof-retirement.cfg | 6 + .../tla/interactive-session/negative.cfg | 6 + .../tla/interactive-session/positive.cfg | 6 + .../tla/interactive-session/wrong-boot.cfg | 6 + .../native-authored-command-dispatch.test.ts | 2 +- tests/native-authored-command.test.ts | 2 +- tests/native-interactive-exec.test.ts | 117 +++ tests/native-interactive-launcher.test.ts | 68 ++ 53 files changed, 4391 insertions(+), 38 deletions(-) create mode 100644 docs/diagrams/interactive-session.mmd create mode 100644 docs/diagrams/interactive-session.svg create mode 100644 packages/runtime-core/src/interactive_exec_cli.rs create mode 100644 packages/runtime-core/src/provider/engine/interactive_exec.rs create mode 100644 packages/runtime-core/src/provider/graph/interactive_exec.rs create mode 100644 packages/runtime-core/src/provider/interactive_handoff.rs create mode 100644 packages/runtime-core/src/provider/interactive_session.rs create mode 100644 packages/runtime-core/src/provider/interactive_terminal.rs create mode 100644 src/backends/native-interactive-exec.ts create mode 100644 tests/fixtures/environment-launcher-tty.py create mode 100644 tests/fixtures/native-interactive-frontend.py create mode 100644 tests/fixtures/native-interactive-frontend.ts create mode 100644 tests/fixtures/native-interactive-runtime.py create mode 100644 tests/fixtures/native-interactive-terminal.py create mode 100644 tests/models/tla/interactive-envelope/COVERAGE.md create mode 100644 tests/models/tla/interactive-envelope/Envelope.tla create mode 100644 tests/models/tla/interactive-envelope/negative.cfg create mode 100644 tests/models/tla/interactive-envelope/positive.cfg create mode 100644 tests/models/tla/interactive-session/COVERAGE.md create mode 100644 tests/models/tla/interactive-session/Session.tla create mode 100644 tests/models/tla/interactive-session/eof-retirement.cfg create mode 100644 tests/models/tla/interactive-session/negative.cfg create mode 100644 tests/models/tla/interactive-session/positive.cfg create mode 100644 tests/models/tla/interactive-session/wrong-boot.cfg create mode 100644 tests/native-interactive-exec.test.ts create mode 100644 tests/native-interactive-launcher.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bcd3d5d9..8ea6d903e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -311,6 +311,15 @@ jobs: (cd "$destination" && echo "$checksum $archive" | shasum -a 256 --check) tar -xf "$destination/$archive" -C "$destination" echo "$destination/zig-${platform}-0.15.2" >> "$GITHUB_PATH" + - name: Test interactive guest launcher on a real Linux PTY + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + test "$(zig version)" = '0.15.2' + command -v python3 + zig build-exe packages/runtime-core/guest/environment-launcher.zig -target x86_64-linux-musl -O ReleaseSmall -femit-bin="$RUNNER_TEMP/interactive-environment-launcher" + python3 tests/fixtures/environment-launcher-tty.py "$RUNNER_TEMP/interactive-environment-launcher" - name: Check candidate formatting run: cargo fmt --manifest-path packages/runtime-core/Cargo.toml --check - name: Lint candidate core diff --git a/docs/diagrams/interactive-session.mmd b/docs/diagrams/interactive-session.mmd new file mode 100644 index 000000000..781ea40a3 --- /dev/null +++ b/docs/diagrams/interactive-session.mmd @@ -0,0 +1,35 @@ +--- +title: Session · admission and cleanup proof +--- +stateDiagram-v2 + direction TB + [*] --> new + new --> admitted : Admit + admitted --> withdrawn : Withdraw [undispatched] + admitted --> created : Create + created --> recorded : Record + recorded --> started : Start [fresh] + started --> attached : Attach + started --> ended : EOF + attached --> ended : EOF + admitted --> abandoned : Crash + created --> abandoned : Crash + recorded --> abandoned : Crash + started --> abandoned : Crash + attached --> abandoned : Crash + ended --> abandoned : Crash + ended --> retired : Retire [exact proof] + abandoned --> retired : Retire [exact proof] + note right of recorded + INV WriteAhead + Intent and exec ID durable before Start + end note + note right of abandoned + INV NoReplay + No recovered admission capability + end note + note right of retired + INV RetiredWithProof + Same-incarnation container absence + or proven bound VM boot stop + end note diff --git a/docs/diagrams/interactive-session.svg b/docs/diagrams/interactive-session.svg new file mode 100644 index 000000000..8b48514c2 --- /dev/null +++ b/docs/diagrams/interactive-session.svg @@ -0,0 +1,66 @@ +Session · admission and cleanup proofAdmitWithdraw [undispatched]CreateRecordStart [fresh]AttachEOFEOFCrashCrashCrashCrashCrashCrashRetire [exact proof]Retire [exact proof]newadmittedwithdrawncreatedstartedattachedendedrecordedabandonedretiredINV WriteAheadIntent and exec ID durable before StartINV NoReplayNo recovered admission capabilityINV RetiredWithProofSame-incarnation container absenceor proven bound VM boot stop \ No newline at end of file diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index b6565a195..63e6523f3 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -3980,8 +3980,9 @@ are allowed. With the same explicit native binary/home selection used for `up`, run `hack exec --path /absolute/project SERVICE -- /bin/echo hello`. The command targets the current owned project/branch mapping, preserves binary stdout/stderr and the -command exit code, and never falls back to Compose. It is noninteractive, with a -30-second observation budget; a timeout may leave the command running and is not +command exit code, and never falls back to Compose. With piped input/output or +`HACK_NO_INTERACTIVE=1`, it uses finite exec with a 30-second observation budget; +a timeout may leave the command running and is not retried. `--env` and `--profile` changes are refused. A command name such as `bun` uses the selected container's PATH; no host PATH or implicit shell is used. Normal `hack exec` resolves the saved startup overlay and AWS profile again, and delivers @@ -3997,6 +3998,50 @@ refuse before execution; restart with the matching candidate to adopt this path. Services without managed values use ordinary exec. The native live qualification of this fresh-delivery path remains separate from its unit and transport tests. +With terminal stdin and stdout, `hack exec SERVICE -- /bin/sh` selects interactive +exec. It streams both guest output channels through the terminal, forwards resize +and terminal control bytes, and releases the provider mutation lease after attach. +`down` and `restart` can proceed while the session is open. Authored projects use +their foreground owner for admission and inherit startup environment values. + +For adopted services with managed values, the pinned guest launcher disables echo +and enters raw mode before announcing readiness. Hack then sends a bounded base64 +private envelope, waits for the launcher's acknowledgement after terminal +restoration, and only then forwards user input. The envelope cannot contain Docker +detach-control bytes. Old launcher mounts refuse before any value is sent; restart +the service with the matching runtime to enable this mode. The launcher returns +125 if admission or exec fails, including an exec failure after acknowledgement. +This does not isolate values from other processes running as the same container +user, which already share access to the command's environment. + +Interactive managed-env exec refuses if fresh delivery is unavailable; it does +not reuse mounted startup values. Finite exec retains its existing behavior. + +An interactive command's exit code does not prove its descendants are gone. Hack +retains a value-free session receipt and prints `session_cleanup_uncertain` with +its UUID and exact `hack down` remedy until the same container incarnation is +absent or its VM boot is proven stopped. EOF, detach, a dead exec process and a +changed boot alone are not cleanup proof. No uncertain command is replayed. + +### Interactive exec acceptance + +On the built current-branch runtime and CLI, exercise authored and adopted services: + +- A shell with foreground/background jobs; Ctrl-C, Ctrl-Z, `fg` and resize. +- Ctrl-P Ctrl-Q disconnect, retaining its UUID and cleanup remedy. +- A background process that survives shell exit; receipt remains until `down`. +- `down` and restart from another terminal while the session is active; the owner + stays responsive and the old session never attaches to the new generation. +- Native child death and frontend death, including an idle terminal; attributes + restore, owned children settle, and uncertain sessions remain discoverable. +- VM loss, followed by exact stopped-boot proof; a new boot alone cannot retire + an older receipt. +- Fresh adopted env with a synthetic canary and detach-control bytes; no private + bytes echo, the intended command receives the values, and an old launcher refuses + before delivery. + +Linux PTY and fake-Engine tests do not qualify these real Engine/VM effects. + Managed HTTPS authority startup uses the same five-second provider-lock admission budget as ordinary pool startup. This lets a brief concurrent graph inspection diff --git a/docs/reference/native-authored-commands.md b/docs/reference/native-authored-commands.md index 6115bd2f3..211c17374 100644 --- a/docs/reference/native-authored-commands.md +++ b/docs/reference/native-authored-commands.md @@ -4,7 +4,7 @@ An explicit `HACK_RUNTIME_BACKEND=native` selection delegates an authored `.hack/hack.project.json` project to the separate native foreground owner. The current command slice supports whole-project foreground or detached `up` and foreground `restart`, owner-mediated `down` and explicit stored-generation `down --recover`, saved-run -`ps`, live service `logs --follow`, finite `logs --no-follow`, noninteractive service `exec` and one-off +`ps`, live service `logs --follow`, finite `logs --no-follow`, terminal or finite service `exec` and one-off service `run` on macOS with an absolute `HACK_NATIVE_BINARY` and private canonical `HACK_NATIVE_HOME`. It requires the caller's separately prepared native provider pool and matching runtime binary. @@ -75,7 +75,7 @@ provider contact. A refusal names no option value, path or project detail. | `down --recover` | Explicit retirement of a complete stored generation whose owners are dead, or of a pending startup intent that never reserved runtime state; omit `--profile` | `--json`, `--detach`, explicit empty or named `--profile`, `--env` overrides, service subsets, single-service, command, workdir, follow, tail and format options | | `ps` | `ps` and `ps --json` for the exact saved project and explicit `--branch` | `--profile`, `--env`, service subsets or a single service, `--recover`, `--detach`, command, workdir, follow, tail and format options | | `logs` | `logs SERVICE --follow` (the default), or `--no-follow`; `--tail` from 0 through 1000 with follow, 1 through 1000 with no-follow (default 200), plain or `--json`; `--since` and `--timestamps` with follow | Omitted, multiple or malformed service selection, non-integer or out-of-range `--tail`, pretty format, `--until`, query filters, `--profile`, `--env`, `--recover`, `--detach`, command and workdir options; TTY-configured services in follow mode | -| `exec` | `exec SERVICE -- COMMAND ARG...` with closed stdin, no TTY and an optional absolute `--workdir` | Empty command or empty first argument, NUL bytes, more than 256 arguments, an argument over 16 KiB or 64 KiB in total, relative `--workdir`, interactive or TTY input, `--json`, `--profile`, `--env`, service subsets, follow, tail and format options, `--recover`, `--detach` | +| `exec` | `exec SERVICE -- COMMAND ARG...`; terminal input/output selects a TTY session, otherwise finite exec with closed stdin; optional absolute `--workdir` | Empty command or empty first argument, NUL bytes, more than 256 arguments, an argument over 16 KiB or 64 KiB in total, relative `--workdir`, `--json`, `--profile`, `--env`, service subsets, follow, tail and format options, `--recover`, `--detach` | | `run` | `run SERVICE -- COMMAND ARG...` through a ready foreground owner, closed stdin, no TTY, optional absolute `--workdir`; member must have no mounts | The `exec` option refusals above; source, persistent-data or private-env mounts refuse before the one-off effect | | `restart` | Whole-project proven stop followed by fresh foreground `up`; `--branch`, `--profile` and `--env` select the fresh generation | Service subsets, `--json`, `--detach`, `--target`, recovery and other unsupported foreground options; missing or unproven stop prevents startup | @@ -498,8 +498,8 @@ not qualify a live provider or application log behavior. `exec SERVICE -- COMMAND ARG...` sends one command to an already running authored service through its authenticated foreground owner. Arguments are literal, including empty arguments, spaces and shell characters. `--workdir` selects an absolute guest -directory; the service's existing environment and user remain in force. Stdin is -closed and no TTY is allocated. Interactive input, TTY control, env/profile overrides, +directory; the service's existing environment and user remain in force. Without +terminal input and output, stdin is closed and no TTY is allocated. Env/profile overrides, multiple services and JSON formatting are unsupported. No shell is inserted. The owner verifies the admitted service, full resource ownership, guest boot, image, @@ -520,6 +520,51 @@ project is not stopped. Inspect the service before deciding whether another command is appropriate. Offline controls do not qualify installed native exec or live application behavior. +## Interactive service exec + +When both stdin and stdout are terminals, `hack exec SERVICE -- /bin/sh` opens a +TTY session. `HACK_NO_INTERACTIVE=1` selects the finite path instead. The shell is +explicit: Hack never inserts one. Authored commands inherit the service's startup +environment. The authenticated owner checks the same service and generation, +creates and starts the exec, then hands its connected Engine stream to the native +terminal child. The owner remains available for `down` and `restart`; no mutation +lease is held during the session. + +The native home holds at most 1024 retained sessions across all projects. +`session_receipt_limit` refuses further admission without discarding evidence; +run `hack down` for a project with retained sessions to release its receipts. +This includes normally exited commands whose background descendants remain +unproven. The command's known exit status is still preserved. + +Terminal output combines the command's stdout and stderr. Ctrl-C, Ctrl-Z and other +terminal bytes go to the guest PTY; window-size changes are forwarded. Docker's +Ctrl-P Ctrl-Q sequence disconnects the transport. Disconnecting does not prove +that the command or its descendants stopped. The native child restores terminal +attributes on exit or frontend loss; the frontend has a second snapshot for native +child failure. If restoration fails, Hack names `stty sane` as the repair. + +Before create, Hack durably records a value-free session UUID bound to the exact +owner, run, boot, container and start generation; it records the exec ID before +start. A known refusal before create dispatch withdraws only its own intent; +uncertain dispatch retains it. Neither operation is replayed. An exec exit code is returned when +known, but EOF or process exit cannot retire the session receipt because background +descendants may remain. Retained evidence produces `session_cleanup_uncertain`, +the UUID and an exact project/branch `hack down` command. Only same-incarnation +container absence or a proven stop of the bound VM boot retires it. A new boot +alone is not proof. Receipt bookkeeping failures do not block container cleanup. + +![Interactive session states](../diagrams/interactive-session.svg) + +The [diagram source](../diagrams/interactive-session.mmd) and +[model coverage](../../tests/models/tla/interactive-session/COVERAGE.md) name the +guards and finite proof limits. Retirement is also allowed from any earlier +intent-bearing state once the same exact cleanup proof holds. + +The PTY fixtures qualify local input/output, cancellation, private delivery and +restoration boundaries. Real Engine job control, detach keys, macOS descriptor +handoff, down/restart during a session and VM loss require the real-VM acceptance +run described in the [candidate guide](../guides/native-candidate.md#interactive-exec-acceptance). + ## Finite one-off service run `run SERVICE -- COMMAND ARG...` sends one command to the authenticated foreground diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 30f5db75a..e6866cee9 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -36,6 +36,8 @@ must be attached to the eventual exact integration revision. | Property and critical states | Model coverage | Implementation/regression boundary | Remaining qualification | | --- | --- | --- | --- | +| Interactive exec write-ahead admission, crash, EOF, replacement boot and retirement | interactive-session (180 states); no-replay, EOF-retirement and wrong-boot negative controls | `interactive_session.rs` atomic intent/exec records, exact stopped-boot proof, known-undispatched withdrawal and lock-free observation/revalidation tests; Engine effect fences; authored owner start and SCM_RIGHTS handoff | One session, two boot identities, two possible starts. Atomic durable publication and exact identity/absence proofs are assumptions; real fsync/power loss, real owner/Engine handoff and VM loss need Mac acceptance. No descendant cleanup inferred from exec exit. | +| Private interactive envelope, echo state and bytes still in flight on failure | interactive-envelope (15 states); restore-on-error negative control | HKEE2 launcher and host gate, Linux PTY seven-case matrix, bounded marker/ACK and coalesced-output tests | Two abstract private bytes, one handshake. PTY fixtures prove local termios ordering; Docker detach scanning, real guest wiring, same-user access and VM effects remain separate. Source/compiled Bun PTY and native-process fixtures cover lifetime/terminal boundaries, not model liveness. | | Native log follower versus lifecycle mutation, frontend loss, stopped container and transport loss | No new state model: the follower observes without a mutation lease and grants no cleanup authority | `provider/graph/log_follow.rs` and its native counterpart pin Ready admission, container and start generation; `provider/engine/log_follow.rs` bounds framing and distinguishes real EOF from stopped-service idle cutoff. `lifecycle_log_follow_tests.rs` keeps the production transport on a fake Engine socket active while acquiring startup and operation leases, with a same-lock contention control and output while each lease is held. `native-log-follow-source.test.ts` guards the observer-only route; process tests cover cancellation, frontend loss, real output-pipe closure while idle, replacement-container restart (including retained adopted run/owner IDs) with one marker, NDJSON admission failures, truncated/partial output and slow idle-end delivery. `native-log-restart.test.ts` covers bounded re-admission, unchanged-container refusal and prompt down; `native-log-selection.test.ts` covers strict finite admission responses and owned-child cancellation/timeouts; advisory markers grant no authority | 4 KiB payload envelopes, 8 KiB wire lines, 64 Ki-character retained payload prefixes excluding Engine timestamps, 30-second restart wait, 1000-line new-container backlog. The portable lease test fakes identity/state checks; it does not exercise host process/disk auditing, graph admission or the VM relay. Real relay EOF on stop/down/restart, VM loss and concurrent lifecycle latency require Apple Silicon acceptance. No performance or whole-product parity claim. | | Foreground group interrupt; publisher exit before authenticated stop; dead owner's pending cleanup enrollment and bridge release | native-publisher-stop | [Process adapter](../../src/backends/native-project-process.ts) isolates Compose owners regardless of hooks; Rust `process::publisher_command` isolates publishers; `legacy_cleanup_enrollment` binds the old coordinator and inventories before a distinct recovery intent. [Group regression](../../tests/native-foreground-group-interrupt.test.ts) signals the whole group for fresh/restore owners; Rust real-publisher process, frontend-loss, exact child-reaping and enrollment tests check grouping, lifetime wakeups and stale selectors. | One graph, one publisher, one crash, one identity replacement and one audited successor boot; 168 states. Publisher exit observation is separate from lease-bound reaping/proof commitment; `NoUncommittedExit` rejects owner cleanup while proof is pending. A busy lease stutters without delaying stop control. SIGHUP requests retaining cleanup; a reaped publisher enters an observable publisher-down state until explicit stop. Native process tests and frontend framing/ps tests cover signal delivery and notices. Dependency release is a separate post-completion step; `NoStrandedReservation` rejects success with a held reservation. Real reservation-file regressions check retry, capacity reuse and refusal fences. Separate stopped-receipt and intent-completion commits are collapsed; TypeScript dispatch and Rust completion-digest regressions cover that interruption window. Publication-registry sync/rename is abstracted and covered by portable commit-interruption tests. Kernel identity, socket/inode proofs, guest observations, durable storage and existing next.13 home recovery remain implementation/Apple Silicon VM gates. No eventual recovery is asserted. | | Exclusive claims; competing writers; partial owner publication | graph-admission, registry-writer, publication-journal, missing-publication-lock, native-frontend-recovery | Admission/registry locks, pending publication and exact saved owner; related runtime ownership tests | Real filesystems, boot/incarnation identity and unsupported writers remain independent. | diff --git a/packages/runtime-core/guest/environment-launcher.zig b/packages/runtime-core/guest/environment-launcher.zig index 64f0008db..cfa063501 100644 --- a/packages/runtime-core/guest/environment-launcher.zig +++ b/packages/runtime-core/guest/environment-launcher.zig @@ -108,10 +108,118 @@ fn freshExec(args: []const [:0]u8) !void { // stdin was fully consumed to EOF; the command receives no secret input bytes. return execCommand(args[2..], &env); } + +/// The private envelope uses only printable base64, so Docker's detach-key +/// scanner cannot interpret its contents. Nothing is sent until echo is off; +/// user input is withheld until the restored-terminal acknowledgement. +fn interactiveFreshExec(args: []const [:0]u8) !void { + if (args.len < 3 or args.len > 4099) return error.Arguments; + const started = try bootSeconds(); + const saved = try std.posix.tcgetattr(0); + var raw = saved; + raw.iflag.BRKINT = false; + raw.iflag.ICRNL = false; + raw.iflag.INLCR = false; + raw.iflag.IGNCR = false; + raw.iflag.ISTRIP = false; + raw.iflag.IXON = false; + raw.iflag.PARMRK = false; + raw.oflag.OPOST = false; + raw.lflag.ECHO = false; + raw.lflag.ECHONL = false; + raw.lflag.ICANON = false; + raw.lflag.ISIG = false; + raw.lflag.IEXTEN = false; + raw.cflag.CSIZE = .CS8; + raw.cflag.PARENB = false; + raw.cc[@intFromEnum(std.posix.V.MIN)] = 1; + raw.cc[@intFromEnum(std.posix.V.TIME)] = 0; + try std.posix.tcsetattr(0, .NOW, raw); + // On failure leave echo disabled, including for bytes still in flight. + // The guest launcher targets Linux; Zig has no portable tcflush wrapper. + errdefer _ = std.os.linux.ioctl(0, std.os.linux.T.CFLSH, 0); // TCIFLUSH + var hello: [64]u8 = undefined; + const marker = try std.fmt.bufPrint(&hello, "HKEE2 {d}\n", .{started}); + if (try std.posix.write(1, marker) != marker.len) return error.Transport; + var env = try interactiveEnvelope(started); + defer env.deinit(); + try std.posix.tcsetattr(0, .DRAIN, saved); + const ack = "HKEE2 OK\n"; + if (try std.posix.write(1, ack) != ack.len) return error.Transport; + return execCommand(args[2..], &env); +} + +fn privateRead(destination: []u8, started: u64) !void { + var used: usize = 0; + while (used < destination.len) { + const now = try bootSeconds(); + if (now >= started + 5) return error.Expired; + var fds = [_]std.posix.pollfd{.{ .fd = 0, .events = std.posix.POLL.IN, .revents = 0 }}; + if (try std.posix.poll(&fds, @intCast((started + 5 - now) * 1000)) == 0) return error.Expired; + const n = try std.posix.read(0, destination[used..]); + if (n == 0) return error.Transport; + used += n; + } +} + +fn interactiveEnvelope(started: u64) !std.process.EnvMap { + var header: [32]u8 = undefined; + defer std.crypto.secureZero(u8, &header); + var used: usize = 0; + while (true) { + if (used == header.len) return error.Payload; + try privateRead(header[used..][0..1], started); + used += 1; + if (header[used - 1] == '\n') break; + } + if (!std.mem.startsWith(u8, header[0..used], "HKEE2 ")) return error.Payload; + const digits = header[6 .. used - 1]; + if (digits.len == 0 or digits[0] == '0') return error.Payload; + for (digits) |c| if (!std.ascii.isDigit(c)) return error.Payload; + const length = try std.fmt.parseInt(usize, digits, 10); + const encoded = try allocator.alloc(u8, 87384); + defer { + std.crypto.secureZero(u8, encoded); + allocator.free(encoded); + } + if (length == 0 or length > encoded.len) return error.Payload; + try privateRead(encoded[0..length], started); + const decoder = std.base64.standard.Decoder; + const decoded_length = try decoder.calcSizeForSlice(encoded[0..length]); + if (decoded_length == 0 or decoded_length > 65536) return error.Payload; + const decoded = try allocator.alloc(u8, 65536); + defer { + std.crypto.secureZero(u8, decoded); + allocator.free(decoded); + } + try decoder.decode(decoded[0..decoded_length], encoded[0..length]); + // JSON may allocate decoded escape sequences. Keep all parse storage in a + // bounded arena which is wiped before the acknowledgement, too. + const storage = try allocator.alloc(u8, 512 * 1024); + defer { + std.crypto.secureZero(u8, storage); + allocator.free(storage); + } + var fixed = std.heap.FixedBufferAllocator.init(storage); + const parsed = try std.json.parseFromSlice(std.json.Value, fixed.allocator(), decoded[0..decoded_length], .{ .duplicate_field_behavior = .@"error" }); + defer parsed.deinit(); + const values = try freshValues(parsed.value, started, try bootSeconds()); + var env = try std.process.getEnvMap(allocator); + errdefer env.deinit(); + var entries = values.iterator(); + while (entries.next()) |entry| try env.put(entry.key_ptr.*, entry.value_ptr.string); + // The host is still gating keystrokes. Pending bytes mean a bad envelope, + // not command input; never restore echo over them. + var fds = [_]std.posix.pollfd{.{ .fd = 0, .events = std.posix.POLL.IN, .revents = 0 }}; + if (try std.posix.poll(&fds, 0) != 0) return error.Payload; + _ = try freshValues(parsed.value, started, try bootSeconds()); + return env; +} fn run() !void { try std.posix.setrlimit(.CORE, .{ .cur = 0, .max = 0 }); const raw = try std.process.argsAlloc(allocator); if (raw.len > 1 and std.mem.eql(u8, raw[1], "--exec-environment-stdin-v1")) return freshExec(raw); + if (raw.len > 1 and std.mem.eql(u8, raw[1], "--exec-environment-tty-v2")) return interactiveFreshExec(raw); const health = raw.len > 1 and std.mem.eql(u8, raw[1], "--health"); const mounted_exec = raw.len > 1 and std.mem.eql(u8, raw[1], "--exec-mounted-v1"); if (health) { diff --git a/packages/runtime-core/src/error.rs b/packages/runtime-core/src/error.rs index 7ddf9b888..d2f4605e4 100644 --- a/packages/runtime-core/src/error.rs +++ b/packages/runtime-core/src/error.rs @@ -135,6 +135,9 @@ pub struct PlanChangeDiagnostic { /// `process_refusals` and `plan_change`. #[derive(Debug, Default, Serialize)] pub struct RefusalDiagnostics { + /// Value-free durable interactive-session UUID, never argv or environment. + #[serde(skip_serializing_if = "Option::is_none")] + pub session: Option, /// Every service whose selected process a private wrapper refused, found in /// one pass before any runtime state is reserved. #[serde(skip_serializing_if = "Option::is_none")] @@ -190,6 +193,15 @@ pub struct CandidateError { } impl CandidateError { + pub fn with_session(mut self, session: &str) -> Self { + if crate::provider::environment_recovery::uuid(session) { + self.refusals.get_or_insert_default().session = Some(session.into()); + } + self + } + pub fn session(&self) -> Option<&str> { + self.refusals.as_ref()?.session.as_deref() + } pub fn new(code: &'static str, message: impl Into) -> Self { let message = message.into(); Self { diff --git a/packages/runtime-core/src/interactive_exec_cli.rs b/packages/runtime-core/src/interactive_exec_cli.rs new file mode 100644 index 000000000..393e663f8 --- /dev/null +++ b/packages/runtime-core/src/interactive_exec_cli.rs @@ -0,0 +1,126 @@ +//! The terminal is inherited unchanged; fd 3 is the private duplex control +//! channel. Its input is length-delimited and its output is value-free JSON. +use hack_runtime_core::{ + Candidate, CandidateError, + provider::graph::interactive_exec::{self, Options}, +}; +use std::{ + collections::BTreeMap, + fs::File, + io::Write, + os::fd::{FromRawFd, OwnedFd}, +}; + +fn invalid() -> CandidateError { + CandidateError::new( + "interactive_exec_arguments", + "Use graph exec-interactive --run-id ID --service NAME --expect-owner ID --expect-plan SHA --expect-namespace SHA [--native] [--workdir DIR] [--expect-container ID --expect-generation SHA] -- PROGRAM [ARG...].", + ) +} + +pub fn command(candidate: &Candidate, args: &[&str]) -> Result { + let mut values = BTreeMap::new(); + let mut native = false; + let mut index = 0; + let mut argv = Vec::new(); + while index < args.len() { + let key = args[index]; + index += 1; + if key == "--" { + argv = args[index..].iter().map(|v| (*v).to_owned()).collect(); + break; + } + if key == "--native" && !native { + native = true; + continue; + } + if ![ + "--run-id", + "--service", + "--expect-owner", + "--expect-plan", + "--expect-namespace", + "--workdir", + "--expect-container", + "--expect-generation", + ] + .contains(&key) + { + return Err(invalid()); + } + let value = *args.get(index).ok_or_else(invalid)?; + index += 1; + if values.insert(key, value).is_some() { + return Err(invalid()); + } + } + let get = |key| values.get(key).copied().ok_or_else(invalid); + let options = Options { + run: get("--run-id")?, + service: get("--service")?, + owner: get("--expect-owner")?, + plan: get("--expect-plan")?, + namespace: get("--expect-namespace")?, + native, + argv: &argv, + workdir: values.get("--workdir").copied(), + expected_container: values.get("--expect-container").copied(), + expected_generation: values.get("--expect-generation").copied(), + }; + options.validate()?; + // SAFETY: this dedicated entrypoint owns the explicitly inherited fd 3. + // Duplicate the report end before the terminal session consumes its leash. + if unsafe { libc::fcntl(3, libc::F_GETFD) } < 0 { + return Err(invalid()); + } + let frontend = File::from(unsafe { OwnedFd::from_raw_fd(3) }); + hack_runtime_core::provider::private_input::validate_duplex(&frontend)?; + let mut report = frontend.try_clone().map_err(|_| invalid())?; + let result = interactive_exec::execute(candidate, &options, frontend.into()); + let (code, value) = match result { + Ok(completion) => ( + completion.exit_code, + serde_json::json!({"version":1,"outcome":"completed","completion":completion}), + ), + Err(error) => ( + 1, + serde_json::json!({"version":1,"outcome":"refused","code":error.code,"session":error.session()}), + ), + }; + let bytes = serde_json::to_vec(&value).map_err(|_| invalid())?; + // The report is under 1 KiB and never contains command output or values. + report.write_all(&bytes).map_err(|_| invalid())?; + report.write_all(b"\n").map_err(|_| invalid())?; + Ok(code) +} + +pub fn sessions(candidate: &Candidate, run: &str, service: &str) -> Result<(), CandidateError> { + if run.len() != 32 + || !run + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + || service.is_empty() + || service.len() > 128 + || !service + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)) + { + return Err(invalid()); + } + #[cfg(target_os = "macos")] + { + let records = hack_runtime_core::provider::interactive_session::retained(candidate)?; + let sessions: Vec<_> = records + .into_iter() + .filter(|r| r.binding.run == run && r.binding.service == service) + .map(|r| r.session) + .collect(); + println!("{}", serde_json::json!({"version":1,"sessions":sessions})); + Ok(()) + } + #[cfg(not(target_os = "macos"))] + { + let _ = (candidate, run, service); + Err(invalid()) + } +} diff --git a/packages/runtime-core/src/main.rs b/packages/runtime-core/src/main.rs index 4006d5540..775242237 100644 --- a/packages/runtime-core/src/main.rs +++ b/packages/runtime-core/src/main.rs @@ -1,4 +1,5 @@ mod graph_cli; +mod interactive_exec_cli; mod log_follow_cli; mod normalized_cli; mod prepared_base_cli; @@ -368,6 +369,21 @@ fn run() -> Result<(), CandidateError> { ["graph", "logs-follow", arguments @ ..] => { log_follow_cli::command(&discover_candidate(&requested)?, arguments)?; } + ["graph", "exec-interactive", arguments @ ..] => { + let code = interactive_exec_cli::command(&discover_candidate(&requested)?, arguments)?; + std::process::exit(code); + } + [ + "graph", + "interactive-sessions", + "--run-id", + run, + "--service", + service, + "--json", + ] => { + interactive_exec_cli::sessions(&discover_candidate(&requested)?, run, service)?; + } ["graph", arguments @ ..] => { let candidate = discover_candidate(&requested)?; let result = graph_cli::command(&candidate, arguments)?; diff --git a/packages/runtime-core/src/provider/engine.rs b/packages/runtime-core/src/provider/engine.rs index 1a5376d5e..6573feb67 100644 --- a/packages/runtime-core/src/provider/engine.rs +++ b/packages/runtime-core/src/provider/engine.rs @@ -12,6 +12,8 @@ use std::io::Read; use std::path::Path; use std::time::{Duration, Instant}; +#[cfg(any(target_os = "macos", test))] +pub(in crate::provider) mod interactive_exec; #[cfg(any(target_os = "macos", test))] pub(in crate::provider) mod log_follow; #[cfg(any(all(target_os = "macos", feature = "environment-launcher"), test))] @@ -705,6 +707,18 @@ impl<'a> Observer<'a> { Ok(value) } + /// Recheck after failures too: a new-boot 404 cannot prove old-boot absence. + #[cfg(target_os = "macos")] + pub(super) fn get_until(&self, path: &str, deadline: Instant) -> Result { + self.guest.verify()?; + let bytes = self + .transport + .request_bytes_until(Method::GET, path, None, Some(deadline)); + self.guest.verify()?; + serde_json::from_slice(&bytes?) + .map_err(|_| failure("Malformed engine response; values omitted.")) + } + pub(super) fn storage_usage(&self) -> Result { self.info()?; self.guest.verify()?; diff --git a/packages/runtime-core/src/provider/engine/interactive_exec.rs b/packages/runtime-core/src/provider/engine/interactive_exec.rs new file mode 100644 index 000000000..03e1e5be1 --- /dev/null +++ b/packages/runtime-core/src/provider/engine/interactive_exec.rs @@ -0,0 +1,401 @@ +//! One non-replayed TTY start. The caller retains mutation authority only until +//! upgrade; streaming and exact exec inspection use a lease-free connection. +#[cfg(target_os = "macos")] +use super::*; +use crate::CandidateError; +#[cfg(target_os = "macos")] +use crate::provider::interactive_session::{self, Binding, Receipt}; +use base64::Engine as _; +use std::{ + collections::BTreeMap, + io::{Read, Write}, + os::unix::net::UnixStream, + time::{Duration, Instant}, +}; +use zeroize::Zeroizing; + +fn uncertain() -> CandidateError { + CandidateError::new( + "session_cleanup_uncertain", + "Interactive session completion or cleanup is unconfirmed; its durable receipt was retained. No request was replayed.", + ) +} +fn remaining(deadline: Instant) -> Result { + deadline + .checked_duration_since(Instant::now()) + .filter(|v| !v.is_zero()) + .ok_or_else(uncertain) +} + +#[cfg(target_os = "macos")] +pub(in crate::provider) struct Attached { + pub receipt: Receipt, + pub stream: UnixStream, +} + +#[cfg(target_os = "macos")] +impl Engine<'_> { + pub(in crate::provider) fn interactive_start( + &self, + candidate: &Candidate, + binding: Binding, + argv: &[String], + workdir: Option<&str>, + deadline: Instant, + fresh: &dyn Fn() -> Result<(), CandidateError>, + ) -> Result { + if self.cleanup_only || !binding.valid() { + return Err(uncertain()); + } + fresh()?; + self.guest.verify()?; + self.guest.before_effect()?; + let intent = interactive_session::admit(candidate, binding.clone())?; + let session = intent.receipt().session.clone(); + let mut intent = Some(intent); + let mut dispatched = false; + let effect = || -> Result<(), CandidateError> { + fresh()?; + self.guest.verify()?; + self.guest.before_effect()?; + remaining(deadline)?; + Ok(()) + }; + let result: Result = (|| { + effect()?; + let mut body = serde_json::json!({"AttachStdin":true,"AttachStdout":true,"AttachStderr":true,"Tty":true,"Privileged":false,"DetachKeys":"ctrl-p,ctrl-q","Cmd":argv}); + if let Some(path) = workdir { + body["WorkingDir"] = Value::String(path.into()); + } + let transport = Transport::new(&self.guest.engine_socket()?, remaining(deadline)?)?; + effect()?; + dispatched = true; + let created = transport.request( + Method::POST, + &format!("/v1.53/containers/{}/exec", binding.container), + Some(&body), + )?; + let id = created["Id"] + .as_str() + .filter(|id| { + id.len() == 64 + && id + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + }) + .ok_or_else(uncertain)?; + let receipt = intent + .take() + .ok_or_else(uncertain)? + .record_exec(candidate, id)?; + effect()?; + let mut stream = + crate::provider::agent::connect_until(&self.guest.engine_socket()?, deadline) + .map_err(|_| uncertain())?; + // Recheck after connect, immediately before the non-replayed start. + effect()?; + super::relay_exec::start_mode(&mut stream, id, deadline, true)?; + fresh()?; + self.guest.verify()?; + Ok(Attached { receipt, stream }) + })(); + match result { + Err(error) if !dispatched => { + if intent.ok_or_else(uncertain)?.withdraw(candidate).is_err() { + Err(uncertain().with_session(&session)) + } else { + Err(error) + } + } + Err(_) => Err(uncertain().with_session(&session)), + Ok(attached) => Ok(attached), + } + } +} + +/// Values never enter Engine create configuration. Both markers are stripped +/// byte-by-byte, preserving any command output coalesced with the ACK. +pub(in crate::provider) fn deliver( + stream: &mut UnixStream, + values: &BTreeMap, + deadline: Instant, + live: &dyn Fn() -> Result<(), CandidateError>, +) -> Result<(), CandidateError> { + let deadline = deadline.min(Instant::now() + Duration::from_secs(5)); + let marker = line(stream, deadline, live)?; + let now = std::str::from_utf8(&marker) + .ok() + .and_then(|s| s.strip_prefix("HKEE2 ")) + .and_then(|s| s.strip_suffix('\n')) + .filter(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())) + .and_then(|s| s.parse::().ok()) + .ok_or_else(uncertain)?; + let expires = now + .checked_add(remaining(deadline)?.as_secs().max(1)) + .ok_or_else(uncertain)?; + #[derive(serde::Serialize)] + struct Envelope<'a> { + version: u8, + expires: u64, + values: &'a BTreeMap, + } + // Fixed storage avoids leaving private bytes in abandoned Vec allocations. + let mut json = Zeroizing::new(vec![0u8; 65536]); + let mut writer = std::io::Cursor::new(json.as_mut_slice()); + serde_json::to_writer( + &mut writer, + &Envelope { + version: 1, + expires, + values, + }, + ) + .map_err(|_| uncertain())?; + let length = writer.position() as usize; + json.truncate(length); + let encoded = Zeroizing::new(base64::engine::general_purpose::STANDARD.encode(&*json)); + let header = format!("HKEE2 {}\n", encoded.len()); + for mut bytes in [header.as_bytes(), encoded.as_bytes()] { + while !bytes.is_empty() { + live()?; + stream + .set_write_timeout(Some(remaining(deadline)?.min(Duration::from_millis(100)))) + .map_err(|_| uncertain())?; + match stream.write(bytes) { + Ok(0) => return Err(uncertain()), + Ok(n) => bytes = &bytes[n..], + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::WouldBlock + | std::io::ErrorKind::TimedOut + | std::io::ErrorKind::Interrupted + ) => {} + Err(_) => return Err(uncertain()), + } + } + } + let ack = line(stream, deadline, live)?; + if ack != b"HKEE2 OK\n" && ack != b"HKEE2 OK\r\n" { + return Err(uncertain()); + } + live()?; + remaining(deadline)?; + Ok(()) +} +fn line( + stream: &mut UnixStream, + deadline: Instant, + live: &dyn Fn() -> Result<(), CandidateError>, +) -> Result, CandidateError> { + let mut bytes = Vec::with_capacity(64); + loop { + live()?; + if bytes.len() == 64 { + return Err(uncertain()); + } + stream + .set_read_timeout(Some(remaining(deadline)?.min(Duration::from_millis(100)))) + .map_err(|_| uncertain())?; + let mut byte = [0]; + match stream.read(&mut byte) { + Ok(0) => return Err(uncertain()), + Ok(_) => { + bytes.push(byte[0]); + if byte[0] == b'\n' { + return Ok(bytes); + } + } + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::WouldBlock + | std::io::ErrorKind::TimedOut + | std::io::ErrorKind::Interrupted + ) => {} + Err(_) => return Err(uncertain()), + } + } +} + +#[cfg(target_os = "macos")] +impl Observer<'_> { + /// Narrow capability: resize only an already-created exec in this exact + /// running container generation. No arbitrary lease-free Engine POST. + pub(in crate::provider) fn interactive_resize( + &self, + receipt: &Receipt, + rows: u16, + cols: u16, + ) -> Result<(), CandidateError> { + let deadline = Instant::now() + Duration::from_millis(750); + self.interactive_binding(receipt, deadline)?; + let id = receipt.exec.as_deref().ok_or_else(uncertain)?; + let transport = Transport::new(&self.guest.engine_socket()?, Duration::from_millis(750))?; + transport.request_bytes_until( + Method::POST, + &format!("/v1.53/exec/{id}/resize?h={rows}&w={cols}"), + None, + Some(deadline), + )?; + self.guest.verify()?; + Ok(()) + } + fn interactive_binding( + &self, + receipt: &Receipt, + deadline: Instant, + ) -> Result { + let b = &receipt.binding; + if !receipt.valid() || self.incarnation() != b.owner || self.guest.boot_id()? != b.boot { + return Err(uncertain()); + } + let value = self.get_until(&format!("/v1.53/containers/{}/json", b.container), deadline)?; + if value["Id"] != b.container + || value["State"]["StartedAt"] != b.generation + || value["State"]["Running"] != true + { + return Err(uncertain()); + } + let id = receipt.exec.as_deref().ok_or_else(uncertain)?; + let exec = self.get_until(&format!("/v1.53/exec/{id}/json"), deadline)?; + if exec["ID"] != id || exec["ContainerID"] != b.container { + return Err(uncertain()); + } + Ok(exec) + } + pub(in crate::provider) fn interactive_exit( + &self, + receipt: &Receipt, + ) -> Result { + let deadline = Instant::now() + Duration::from_secs(2); + wait_exit(deadline, &mut || { + self.interactive_binding(receipt, deadline) + }) + } +} + +fn wait_exit( + deadline: Instant, + inspect: &mut dyn FnMut() -> Result, +) -> Result { + loop { + remaining(deadline)?; + let value = inspect()?; + remaining(deadline)?; + if value["Running"] == false { + return value["ExitCode"] + .as_i64() + .filter(|c| (0..=255).contains(c)) + .map(|c| c as i32) + .ok_or_else(uncertain); + } + if value["Running"] != true { + return Err(uncertain()); + } + std::thread::sleep(remaining(deadline)?.min(Duration::from_millis(50))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn eof_can_precede_exit_publication_but_polling_is_bounded_and_fail_closed() { + let mut calls = 0; + assert_eq!( + wait_exit(Instant::now() + Duration::from_secs(1), &mut || { + calls += 1; + Ok(serde_json::json!({"Running":calls < 3,"ExitCode":17})) + }) + .unwrap(), + 17 + ); + assert_eq!(calls, 3); + assert_eq!( + wait_exit(Instant::now() + Duration::from_millis(10), &mut || Ok( + serde_json::json!({"Running":true}) + )) + .unwrap_err() + .code, + "session_cleanup_uncertain" + ); + let mut calls = 0; + assert_eq!( + wait_exit(Instant::now() + Duration::from_secs(1), &mut || { + calls += 1; + Err(CandidateError::new("selection_changed", "Fixed refusal.")) + }) + .unwrap_err() + .code, + "selection_changed" + ); + assert_eq!(calls, 1); + } + #[test] + fn private_handshake_preserves_coalesced_command_output() { + for ack in [b"HKEE2 OK\n".as_slice(), b"HKEE2 OK\r\n"] { + let (mut client, mut server) = UnixStream::pair().unwrap(); + let ack = ack.to_vec(); + let worker = std::thread::spawn(move || { + server.write_all(b"HKEE2 123\n").unwrap(); + let header = line( + &mut server, + Instant::now() + Duration::from_secs(2), + &|| Ok(()), + ) + .unwrap(); + let length: usize = std::str::from_utf8(&header) + .unwrap() + .trim() + .strip_prefix("HKEE2 ") + .unwrap() + .parse() + .unwrap(); + let mut encoded = vec![0; length]; + server.read_exact(&mut encoded).unwrap(); + assert!(!encoded.contains(&0x10) && !encoded.contains(&0x11)); + let json = base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap(); + let value: serde_json::Value = serde_json::from_slice(&json).unwrap(); + assert_eq!(value["values"]["TOKEN"], "synthetic\u{10}\u{11}"); + server + .write_all(&[ack, b"early-output".to_vec()].concat()) + .unwrap(); + }); + deliver( + &mut client, + &BTreeMap::from([("TOKEN".into(), "synthetic\u{10}\u{11}".into())]), + Instant::now() + Duration::from_secs(2), + &|| Ok(()), + ) + .unwrap(); + let mut output = [0; 12]; + client.read_exact(&mut output).unwrap(); + assert_eq!(&output, b"early-output"); + worker.join().unwrap(); + } + } + #[test] + fn old_or_malformed_launcher_receives_no_private_bytes() { + for marker in [ + b"HKEE1 123\n".as_slice(), + b"bad\n", + b"HKEE2 private-canary\n", + ] { + let (mut client, mut server) = UnixStream::pair().unwrap(); + server.write_all(marker).unwrap(); + let error = deliver( + &mut client, + &BTreeMap::new(), + Instant::now() + Duration::from_secs(1), + &|| Ok(()), + ) + .unwrap_err(); + assert!(!error.message.contains("canary")); + client.shutdown(std::net::Shutdown::Both).unwrap(); + assert_eq!(server.read(&mut [0]).unwrap(), 0); + } + } +} diff --git a/packages/runtime-core/src/provider/engine/relay_exec.rs b/packages/runtime-core/src/provider/engine/relay_exec.rs index 0910f4f0c..a4790b839 100644 --- a/packages/runtime-core/src/provider/engine/relay_exec.rs +++ b/packages/runtime-core/src/provider/engine/relay_exec.rs @@ -122,7 +122,19 @@ pub(super) fn start( id: &str, deadline: Instant, ) -> Result<(), CandidateError> { - let body = r#"{"Detach":false,"Tty":false}"#; + start_mode(stream, id, deadline, false) +} +pub(super) fn start_mode( + stream: &mut UnixStream, + id: &str, + deadline: Instant, + tty: bool, +) -> Result<(), CandidateError> { + let body = if tty { + r#"{"Detach":false,"Tty":true}"# + } else { + r#"{"Detach":false,"Tty":false}"# + }; let request = format!( "POST /v1.53/exec/{id}/start HTTP/1.1\r\nHost: hack-local\r\nConnection: Upgrade\r\nUpgrade: tcp\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", body.len() @@ -238,6 +250,26 @@ mod tests { bytes } #[test] + fn interactive_upgrade_preserves_output_in_the_header_read() { + let (mut client, mut server) = UnixStream::pair().unwrap(); + let worker = std::thread::spawn(move || { + let wire = request(&mut server); + assert!(wire.ends_with(br#"{"Detach":false,"Tty":true}"#)); + server.write_all(b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\nfirst-output").unwrap(); + }); + start_mode( + &mut client, + &"a".repeat(64), + Instant::now() + Duration::from_secs(2), + true, + ) + .unwrap(); + let mut output = Vec::new(); + client.read_to_end(&mut output).unwrap(); + assert_eq!(output, b"first-output"); + worker.join().unwrap(); + } + #[test] fn real_upgrade_keeps_private_stdin_out_of_http_and_reads_fragmented_output() { let (mut client, mut server) = UnixStream::pair().unwrap(); let worker = std::thread::spawn(move || { diff --git a/packages/runtime-core/src/provider/environment_recovery.rs b/packages/runtime-core/src/provider/environment_recovery.rs index 9d6d82295..47e61ead5 100644 --- a/packages/runtime-core/src/provider/environment_recovery.rs +++ b/packages/runtime-core/src/provider/environment_recovery.rs @@ -57,7 +57,7 @@ fn hex(value: &str, len: usize) -> bool { .bytes() .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) } -pub(super) fn uuid(value: &str) -> bool { +pub(crate) fn uuid(value: &str) -> bool { value.len() == 36 && value.bytes().enumerate().all(|(i, c)| { if [8, 13, 18, 23].contains(&i) { diff --git a/packages/runtime-core/src/provider/graph/foreground/transport.rs b/packages/runtime-core/src/provider/graph/foreground/transport.rs index 58f4434a4..bb2cbd4ae 100644 --- a/packages/runtime-core/src/provider/graph/foreground/transport.rs +++ b/packages/runtime-core/src/provider/graph/foreground/transport.rs @@ -1525,7 +1525,7 @@ pub(in crate::provider::graph) fn resume_recovered_publisher( /// The framed client shuts down only its write side. A read-side EOF therefore /// cannot prove abandonment; EVFILT_WRITE reports EV_EOF when its read side closes. -pub(super) struct ClientWatch { +pub(in crate::provider::graph) struct ClientWatch { queue: OwnedFd, } impl ClientWatch { diff --git a/packages/runtime-core/src/provider/graph/interactive_exec.rs b/packages/runtime-core/src/provider/graph/interactive_exec.rs new file mode 100644 index 000000000..434aaca75 --- /dev/null +++ b/packages/runtime-core/src/provider/graph/interactive_exec.rs @@ -0,0 +1,207 @@ +//! Exact interactive admission followed by a lease-free terminal lifetime. +use crate::{Candidate, CandidateError}; +use std::os::fd::OwnedFd; + +pub struct Options<'a> { + pub run: &'a str, + pub service: &'a str, + pub owner: &'a str, + pub plan: &'a str, + pub namespace: &'a str, + pub native: bool, + pub argv: &'a [String], + pub workdir: Option<&'a str>, + pub expected_container: Option<&'a str>, + pub expected_generation: Option<&'a str>, +} +fn refused() -> CandidateError { + CandidateError::new( + "interactive_exec_refused", + "Interactive exec requires a current ready service, exact ownership and a foreground terminal; no fallback was attempted.", + ) +} +impl Options<'_> { + pub fn validate(&self) -> Result<(), CandidateError> { + if !super::hex(self.run, 32) + || !super::hex(self.owner, 32) + || !super::hex(self.plan, 64) + || !super::hex(self.namespace, 64) + || self.service.is_empty() + || self.service.len() > 128 + || !self + .service + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)) + || self.argv.is_empty() + || self.argv.len() > 256 + || self.argv[0].is_empty() + || self + .argv + .iter() + .any(|s| s.len() > 16384 || s.contains('\0')) + || self.argv.iter().map(String::len).sum::() > 65536 + || self + .workdir + .is_some_and(|s| !s.starts_with('/') || s.len() > 4096 || s.contains('\0')) + || self + .expected_container + .is_some_and(|id| !super::hex(id, 64)) + || self + .expected_generation + .is_some_and(|id| !super::hex(id, 64)) + || self.expected_container.is_some() != self.expected_generation.is_some() + { + return Err(refused()); + } + Ok(()) + } +} + +#[derive(serde::Serialize)] +pub struct Completion { + pub exit_code: i32, + pub session: String, + pub retained: bool, +} +pub fn execute( + candidate: &Candidate, + options: &Options<'_>, + frontend: OwnedFd, +) -> Result { + options.validate()?; + #[cfg(target_os = "macos")] + { + native(candidate, options, frontend) + } + #[cfg(not(target_os = "macos"))] + { + let _ = (candidate, frontend); + Err(CandidateError::new( + "interactive_exec_platform", + "Native interactive exec requires macOS.", + )) + } +} + +#[cfg(target_os = "macos")] +fn native( + candidate: &Candidate, + options: &Options<'_>, + frontend: OwnedFd, +) -> Result { + use crate::provider::{ + engine::{Observer, interactive_exec}, + interactive_session, + interactive_terminal::{Leash, Terminal}, + managed_environment, + }; + use std::time::{Duration, Instant}; + let mut terminal = Terminal::open()?; + let ingress = Instant::now(); + let (leash, payload) = Leash::receive(frontend)?; + if options.native && !payload.is_empty() { + return Err(refused()); + } + let managed = if payload.is_empty() { + None + } else { + if options.expected_container.is_none() { + return Err(refused()); + } + Some(managed_environment::receive_interactive_bytes( + &payload, + options.plan, + options.run, + ingress, + )?) + }; + drop(payload); + leash.check()?; + let _ = interactive_session::reconcile_observed(candidate); + let admission = if options.native { + #[cfg(feature = "native-config-plan")] + { + super::native::foreground::interactive( + candidate, + options.run, + options.service, + options.argv, + options.workdir, + (options.owner, options.plan, options.namespace), + &leash, + ) + } + #[cfg(not(feature = "native-config-plan"))] + { + return Err(refused()); + } + } else { + let selected = super::service_selection(candidate, options.run, options.service)?; + if selected.owner != options.owner + || selected.plan != options.plan + || selected.namespace != options.namespace + || options + .expected_container + .is_some_and(|v| v != selected.container) + || options + .expected_generation + .is_some_and(|v| v != selected.generation) + { + return Err(refused()); + } + super::service_exec::interactive( + candidate, + &super::ServiceExecOptions { + run: options.run, + service: options.service, + expected_container: &selected.container, + expected_boot: &selected.boot, + expected_generation: &selected.generation, + argv: options.argv, + workdir: options.workdir, + timeout: Duration::from_secs(30), + }, + managed.as_ref(), + &|| leash.check(), + ) + }; + let mut attached = admission?; + let receipt = &attached.receipt; + let result = (|| { + leash.check()?; + if let Some(managed) = &managed { + interactive_exec::deliver( + &mut attached.stream, + managed.values().get(options.service).ok_or_else(refused)?, + managed.deadline(), + &|| leash.check(), + )?; + } + drop(managed); + let observer = Observer::connect(candidate)?; + crate::provider::interactive_terminal::with_resize_worker( + |rows, cols| { + let _ = observer.interactive_resize(receipt, rows, cols); + }, + |resize| { + terminal.relay(&mut attached.stream, &leash, &mut |rows, cols| { + resize(rows, cols) + }) + }, + )?; + observer.interactive_exit(receipt) + })(); + terminal.restore()?; + drop(attached.stream); + let _ = interactive_session::reconcile_observed(candidate); + let remains = interactive_session::retained(candidate)? + .iter() + .any(|r| r.session == receipt.session); + result + .map(|exit_code| Completion { + exit_code, + session: receipt.session.clone(), + retained: remains, + }) + .map_err(|error| error.with_session(&receipt.session)) +} diff --git a/packages/runtime-core/src/provider/graph/mod.rs b/packages/runtime-core/src/provider/graph/mod.rs index c2b6862f8..0557197c4 100644 --- a/packages/runtime-core/src/provider/graph/mod.rs +++ b/packages/runtime-core/src/provider/graph/mod.rs @@ -1,4 +1,5 @@ //! Fresh owned graph attempts. Recovery only observes or cleans recorded resources; never replay. +pub mod interactive_exec; pub mod one_off; mod service_exec; pub use service_exec::{ @@ -2234,6 +2235,8 @@ fn cleanup_owned_fenced( .into(); state::write(&root.join("state.json"), &receipt)?; fence()?; + #[cfg(target_os = "macos")] + crate::provider::interactive_session::after_cleanup(candidate); Ok(receipt) } diff --git a/packages/runtime-core/src/provider/graph/native/foreground.rs b/packages/runtime-core/src/provider/graph/native/foreground.rs index 4b9462592..b9eaad96c 100644 --- a/packages/runtime-core/src/provider/graph/native/foreground.rs +++ b/packages/runtime-core/src/provider/graph/native/foreground.rs @@ -32,6 +32,7 @@ pub enum Action { Cleanup, Logs, Exec, + InteractiveExec, Run, } #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -78,7 +79,7 @@ impl Request { || self.review != review.review_id() || match (&self.action, &self.logs, &self.exec, &self.one_off) { (Action::Logs, Some(logs), None, None) => !logs.valid(), - (Action::Exec, None, Some(exec), None) => !exec.valid(), + (Action::Exec | Action::InteractiveExec, None, Some(exec), None) => !exec.valid(), (Action::Run, None, None, Some(one_off)) => !one_off.valid(), (Action::Status | Action::Cleanup, None, None, None) => false, _ => true, @@ -106,6 +107,13 @@ struct Reply { #[derive(Serialize, Deserialize)] #[serde(tag = "outcome", rename_all = "kebab-case", deny_unknown_fields)] enum Outcome { + Interactive { + receipt: Receipt, + session: crate::provider::interactive_session::Receipt, + }, + InteractiveRefused { + session: String, + }, Exec { exec: runtime::ServiceExec, }, @@ -163,6 +171,37 @@ impl Reply { return Err(refused()); } let receipt = match &self.result { + Outcome::Interactive { receipt, session } => { + let selected = exec_selected.ok_or_else(refused)?; + let binding = &session.binding; + if action != Action::InteractiveExec + || !session.valid() + || session.exec.is_none() + || binding.backend != crate::provider::interactive_session::Backend::Authored + || binding.run != review.scope().run + || binding.namespace != review.scope().namespace + || binding.owner != receipt.owner + || binding.boot != receipt.boot + || binding.service != selected.service + || receipt.phase != Phase::ReadyObserved + || receipt + .resources + .get(&format!("container:{}", selected.service)) + .and_then(|r| r.id.as_deref()) + != Some(binding.container.as_str()) + { + return Err(refused()); + } + receipt + } + Outcome::InteractiveRefused { session } => { + if action != Action::InteractiveExec + || !crate::provider::environment_recovery::uuid(session) + { + return Err(refused()); + } + return Ok(()); + } Outcome::Exec { exec } => { if action != Action::Exec || !exec.valid(exec_selected.ok_or_else(refused)?) { return Err(refused()); @@ -359,7 +398,32 @@ pub fn serve_with_storage_tool( continue; } publication.verify()?; + let mut attached = None; let result = match request.action { + Action::InteractiveExec => { + let selected = request.exec.as_ref().ok_or_else(refused)?; + let client = transport::ClientWatch::new(&stream)?; + let check = || { + publication.verify()?; + if signals.pending() || client.disconnected() { + return Err(refused()); + } + read_pin.verify(candidate) + }; + match runtime::interactive_exec(candidate, &receipt, selected, &check) { + Ok(session) => { + attached = Some(session.stream); + Ok(Outcome::Interactive { + receipt: receipt.clone(), + session: session.receipt, + }) + } + Err(error) if error.session().is_some() => Ok(Outcome::InteractiveRefused { + session: error.session().ok_or_else(refused)?.to_owned(), + }), + Err(error) => Err(error), + } + } Action::Status => inspect(candidate, &run).map(|snapshot| Outcome::Status { snapshot }), Action::Cleanup => clean().map(|receipt| Outcome::Cleaned { receipt }), Action::Exec => { @@ -425,6 +489,20 @@ pub fn serve_with_storage_tool( }; return Ok(receipt); } + if request.action == Action::InteractiveExec { + // The connected stream is handed over before the bound reply; its + // recipient validates that reply before using the fd. Sending + // consumes our copy even on error, so the owner never holds a shell. + if crate::provider::interactive_handoff::send( + &stream, + attached, + std::time::Instant::now() + Duration::from_secs(5), + ) + .is_err() + { + continue; + } + } let _ = transport::write(&mut stream, &reply, Duration::from_secs(5)); } } @@ -438,7 +516,10 @@ pub fn request( candidate: &Candidate, options: RequestOptions<'_>, ) -> Result { - if matches!(options.action, Action::Logs | Action::Exec | Action::Run) { + if matches!( + options.action, + Action::Logs | Action::Exec | Action::InteractiveExec | Action::Run + ) { return Err(refused()); } request_selected(candidate, options.run, options.action, None, None, None) @@ -496,6 +577,76 @@ pub fn one_off( } request_selected(candidate, run, Action::Run, None, None, Some(selected)) } +/// The live owner creates and starts under its normal guards, then returns the +/// connected fd. The caller streams without retaining an owner/provider lease. +pub(in crate::provider) fn interactive( + candidate: &Candidate, + run: &str, + service: &str, + argv: &[String], + workdir: Option<&str>, + pins: (&str, &str, &str), + leash: &crate::provider::interactive_terminal::Leash, +) -> Result { + let selected = runtime::ExecSelection { + service: service.into(), + argv: argv.to_vec(), + workdir: workdir.map(str::to_owned), + }; + if !selected.valid() { + return Err(refused()); + } + let pin = owner::Pin::load(candidate, run)?; + let review = pin.review(); + let expected = journal::read_control(candidate, review)?; + if ( + expected.owner.as_str(), + review.review_id(), + review.scope().namespace, + ) != pins + { + return Err(refused()); + } + let mut stream = pin.connect()?; + let _lifetime = leash.watch_control(&stream)?; + let input = Request { + version: 2, + kind: RequestKind::NativeGraphControl, + run: run.into(), + review: review.review_id().into(), + action: Action::InteractiveExec, + logs: None, + exec: Some(selected.clone()), + one_off: None, + }; + transport::write(&mut stream, &input, Duration::from_secs(5))?; + let descriptor = crate::provider::interactive_handoff::receive( + &stream, + std::time::Instant::now() + Duration::from_secs(40), + )?; + let reply: Reply = transport::read(&mut stream, Duration::from_secs(5), LIMIT)?; + reply.validate( + &expected, + Action::InteractiveExec, + None, + Some(&selected), + None, + )?; + match reply.result { + Outcome::Interactive { session, .. } => { + let result = (|| { + journal::read_control(candidate, review)?.check_binding(&expected)?; + pin.verify()?; + let stream = descriptor.ok_or_else(refused)?; + Ok(crate::provider::engine::interactive_exec::Attached { receipt: session.clone(), stream }) + })(); + result.map_err(|_: CandidateError| CandidateError::new("session_cleanup_uncertain", "Interactive session handoff changed; the session was retained and no start was replayed.").with_session(&session.session)) + } + Outcome::InteractiveRefused { session } => Err(CandidateError::new("session_cleanup_uncertain", "Interactive admission ended after its durable intent; the session was retained and no start was replayed.").with_session(&session)), + Outcome::Refused { code, .. } => Err(refused().with_cause_code(code)), + _ => Err(refused()), + } +} fn request_selected( candidate: &Candidate, run: &str, diff --git a/packages/runtime-core/src/provider/graph/native/runtime.rs b/packages/runtime-core/src/provider/graph/native/runtime.rs index 47db7c101..a0d39cd43 100644 --- a/packages/runtime-core/src/provider/graph/native/runtime.rs +++ b/packages/runtime-core/src/provider/graph/native/runtime.rs @@ -11,6 +11,8 @@ pub(super) use exec::ExecSelection; pub(super) use exec::ServiceExec; #[cfg(target_os = "macos")] pub(super) use exec::execute as service_exec; +#[cfg(target_os = "macos")] +pub(super) use exec::interactive as interactive_exec; #[cfg(any(target_os = "macos", test))] mod run; #[cfg(target_os = "macos")] @@ -1498,6 +1500,8 @@ fn cleanup_inner( finish(&observed)?; check_startup(guard)?; } + #[cfg(target_os = "macos")] + crate::provider::interactive_session::after_cleanup(candidate); Ok(receipt) } #[cfg(test)] diff --git a/packages/runtime-core/src/provider/graph/native/runtime/exec.rs b/packages/runtime-core/src/provider/graph/native/runtime/exec.rs index 717fb2fb8..6d40fdb08 100644 --- a/packages/runtime-core/src/provider/graph/native/runtime/exec.rs +++ b/packages/runtime-core/src/provider/graph/native/runtime/exec.rs @@ -172,3 +172,73 @@ pub(super) fn execute_with( } Ok(result) } + +#[cfg(target_os = "macos")] +pub(in crate::provider::graph::native) fn interactive( + candidate: &Candidate, + expected: &Receipt, + selected: &ExecSelection, + guard: &dyn Fn() -> Result<(), CandidateError>, +) -> Result { + use crate::provider::interactive_session::{Backend as SessionBackend, Binding}; + if !selected.valid() { + return Err(refused()); + } + guard()?; + let deadline = Instant::now() + Duration::from_secs(30); + let engine = Engine::connect_command_until(candidate, deadline)?; + let (receipt, _) = journal::load( + candidate, + expected.review.scope().run, + engine.guest().incarnation(), + engine.guest().boot_id(), + )?; + receipt.check_binding(expected)?; + if receipt.phase != Phase::ReadyObserved || !receipt.readiness.contains_key(&selected.service) { + return Err(refused()); + } + let owned = OwnedBackend { + engine, + launcher: None, + leases: BTreeMap::new(), + }; + let backend = GuardedBackend { + backend: &owned, + guard: Some(guard), + }; + let resource = receipt + .resources + .get(&format!("container:{}", selected.service)) + .filter(|r| r.kind == Kind::Container) + .ok_or_else(refused)?; + let container = resource.id.clone().ok_or_else(refused)?; + let generation = member(&backend, &receipt, resource)?; + let fresh = || { + guard()?; + managed_environment::remaining_until(deadline)?; + journal::read_control(candidate, &expected.review)?.check_binding(expected)?; + snapshot_until(&backend, receipt.clone(), Some(deadline))?; + if member(&backend, &receipt, resource)? != generation { + return Err(refused()); + } + Ok(()) + }; + fresh()?; + owned.engine.interactive_start( + candidate, + Binding { + backend: SessionBackend::Authored, + namespace: receipt.review.scope().namespace.to_owned(), + run: receipt.review.scope().run.to_owned(), + owner: receipt.owner.clone(), + boot: receipt.boot.clone(), + container, + generation: generation.clone(), + service: selected.service.clone(), + }, + &selected.argv, + selected.workdir.as_deref(), + deadline, + &fresh, + ) +} diff --git a/packages/runtime-core/src/provider/graph/service_exec.rs b/packages/runtime-core/src/provider/graph/service_exec.rs index 1a0702a5a..0a0412051 100644 --- a/packages/runtime-core/src/provider/graph/service_exec.rs +++ b/packages/runtime-core/src/provider/graph/service_exec.rs @@ -67,6 +67,130 @@ fn validate(options: &ServiceExecOptions<'_>) -> Result<(), CandidateError> { Ok(()) } +#[cfg(target_os = "macos")] +pub(super) fn interactive( + candidate: &Candidate, + options: &ServiceExecOptions<'_>, + managed: Option<&crate::provider::managed_environment::Managed>, + live: &dyn Fn() -> Result<(), CandidateError>, +) -> Result { + use crate::provider::interactive_session::{Backend, Binding}; + use std::time::Instant; + validate(options)?; + live()?; + let deadline = Instant::now() + options.timeout; + let engine = Engine::connect_command_until(candidate, deadline)?; + let (receipt, root) = load(candidate, &engine, options.run)?; + if receipt.environment_attached && managed.is_none() { + return Err(error( + "interactive_exec_environment_required", + "Interactive exec requires fresh selected-service environment delivery; no startup-environment fallback was attempted.", + )); + } + super::startup::require_dependency_rebind_complete(&root, &receipt)?; + let pending = root + .join("state.pending") + .try_exists() + .map_err(|_| refused())?; + let resource = binding(&receipt, engine.guest().boot_id(), pending, options)?; + let container = inspect_resource(&engine, &receipt, resource)?.ok_or_else(refused)?; + let generation = container["State"]["StartedAt"] + .as_str() + .filter(|s| crate::provider::engine::log_follow::valid_timestamp(s)) + .ok_or_else(refused)? + .to_owned(); + let slot = if receipt.environment_attached { + crate::provider::environment_recovery::active_exec_slot( + candidate, + engine.guest(), + &receipt.run, + options.service, + &resource.name, + mounted_environment_slot(&container)?.as_deref(), + )? + } else { + None + }; + if managed.is_none() && slot.is_some() && !options.argv[0].starts_with('/') { + #[cfg(feature = "environment-launcher")] + require_current_launcher(&container, &super::launcher::current_source())?; + #[cfg(not(feature = "environment-launcher"))] + return Err(refused()); + } + let argv = if let Some(managed) = managed { + #[cfg(feature = "environment-launcher")] + { + managed.validate_binding(&receipt.plan_id, &receipt.run)?; + if !receipt.environment_attached + || managed.values().len() != 1 + || managed + .values() + .get(options.service) + .is_none_or(|v| v.is_empty()) + { + return Err(refused()); + } + require_current_launcher(&container, &super::launcher::current_source())?; + let mut argv = vec![ + "/run/hack-environment-launcher".into(), + "--exec-environment-tty-v2".into(), + ]; + argv.extend_from_slice(options.argv); + argv + } + #[cfg(not(feature = "environment-launcher"))] + { + let _ = managed; + return Err(refused()); + } + } else { + managed_exec_argv(&container, slot.as_deref(), options.argv)? + }; + let fresh = || { + live()?; + crate::provider::managed_environment::remaining_until(deadline)?; + let (current, root) = load(candidate, &engine, options.run)?; + super::startup::require_dependency_rebind_complete(&root, ¤t)?; + binding( + ¤t, + engine.guest().boot_id(), + root.join("state.pending") + .try_exists() + .map_err(|_| refused())?, + options, + )?; + let value = inspect_resource(&engine, &receipt, resource)?.ok_or_else(refused)?; + if value["State"]["Running"] != true + || value["State"]["Paused"] != false + || value["State"]["Restarting"] != false + || value["State"]["StartedAt"] != generation + { + return Err(refused()); + } + Ok(()) + }; + fresh()?; + let attached = engine.interactive_start( + candidate, + Binding { + backend: Backend::Adopted, + namespace: receipt.namespace.clone(), + run: receipt.run.clone(), + owner: receipt.owner.clone(), + boot: engine.guest().boot_id().to_owned(), + container: options.expected_container.to_owned(), + generation: generation.clone(), + service: options.service.to_owned(), + }, + &argv, + options.workdir, + deadline, + &fresh, + )?; + // No provider mutation lease crosses the return into the terminal loop. + Ok(attached) +} + /// Execute argv once with stdin closed and TTY disabled, retaining the VM mutation /// lease throughout. A timeout or interrupted response is uncertain: the command /// may still run and must not be replayed automatically. Inherits container user, diff --git a/packages/runtime-core/src/provider/interactive_handoff.rs b/packages/runtime-core/src/provider/interactive_handoff.rs new file mode 100644 index 000000000..90ed83cea --- /dev/null +++ b/packages/runtime-core/src/provider/interactive_handoff.rs @@ -0,0 +1,331 @@ +//! One descriptor on the already authenticated owner connection. The receiver +//! must validate the following bound reply before using the returned stream. +use crate::CandidateError; +use std::{ + io, + mem::{size_of, zeroed}, + os::fd::{AsRawFd, FromRawFd, OwnedFd}, + os::unix::net::UnixStream, + time::Instant, +}; + +fn refused() -> CandidateError { + CandidateError::new( + "session_cleanup_uncertain", + "Interactive session handoff was not confirmed; its durable receipt was retained. No start was replayed.", + ) +} + +fn wait(stream: &UnixStream, events: i16, deadline: Instant) -> Result<(), CandidateError> { + loop { + let left = deadline + .checked_duration_since(Instant::now()) + .ok_or_else(refused)?; + let mut fd = libc::pollfd { + fd: stream.as_raw_fd(), + events, + revents: 0, + }; + // SAFETY: fd is one initialized poll entry, retained for this call. + let count = unsafe { libc::poll(&mut fd, 1, left.as_millis().clamp(1, 1000) as i32) }; + if count < 0 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted { + continue; + } + if count < 0 { + return Err(refused()); + } + if count > 0 { + if fd.revents & libc::POLLNVAL != 0 { + return Err(refused()); + } + return Ok(()); + } + } +} + +/// Consumes the sender's copy on *every* outcome. There is never a retry after +/// successful sendmsg; a lost reply is an uncertain session, not a new admission. +pub(super) fn send( + control: &UnixStream, + session: Option, + deadline: Instant, +) -> Result<(), CandidateError> { + let mut tag = u8::from(session.is_some()); + let mut iov = libc::iovec { + iov_base: (&mut tag as *mut u8).cast(), + iov_len: 1, + }; + // usize storage provides cmsghdr alignment on both supported platforms. + let mut ancillary = [0usize; 16]; + // SAFETY: msghdr contains integer/pointer fields with valid zero values. + let mut message: libc::msghdr = unsafe { zeroed() }; + message.msg_iov = &mut iov; + message.msg_iovlen = 1; + if let Some(session) = &session { + message.msg_control = ancillary.as_mut_ptr().cast(); + // SAFETY: control storage exceeds CMSG_SPACE for one int; FIRSTHDR and + // DATA point within it. write_unaligned avoids relying on data alignment. + unsafe { + message.msg_controllen = libc::CMSG_SPACE(size_of::() as u32) as _; + let header = libc::CMSG_FIRSTHDR(&message); + (*header).cmsg_level = libc::SOL_SOCKET; + (*header).cmsg_type = libc::SCM_RIGHTS; + (*header).cmsg_len = libc::CMSG_LEN(size_of::() as u32) as _; + libc::CMSG_DATA(header) + .cast::() + .write_unaligned(session.as_raw_fd()); + } + } + loop { + wait(control, libc::POLLOUT, deadline)?; + // SAFETY: all buffers live through sendmsg; the kernel copies the fd, + // which remains owned by session until this function returns. + let count = unsafe { + libc::sendmsg( + control.as_raw_fd(), + &message, + libc::MSG_DONTWAIT | libc::MSG_NOSIGNAL, + ) + }; + if count == 1 { + return Ok(()); + } + if count < 0 + && matches!( + io::Error::last_os_error().kind(), + io::ErrorKind::Interrupted | io::ErrorKind::WouldBlock + ) + { + continue; + } + return Err(refused()); + } +} + +/// Received descriptors are owned immediately, including on truncated/invalid +/// messages. No descriptor is exposed until its count, type and CLOEXEC are valid. +pub(super) fn receive( + control: &UnixStream, + deadline: Instant, +) -> Result, CandidateError> { + loop { + wait(control, libc::POLLIN, deadline)?; + let mut tag = 255u8; + let mut iov = libc::iovec { + iov_base: (&mut tag as *mut u8).cast(), + iov_len: 1, + }; + let mut ancillary = [0usize; 64]; + // SAFETY: zero is a valid initial value for all msghdr fields. + let mut message: libc::msghdr = unsafe { zeroed() }; + message.msg_iov = &mut iov; + message.msg_iovlen = 1; + message.msg_control = ancillary.as_mut_ptr().cast(); + message.msg_controllen = size_of_val(&ancillary) as _; + // SAFETY: the initialized header describes writable local buffers of + // their exact sizes. UnixStream retains the live control descriptor. + #[cfg(target_os = "linux")] + let flags = libc::MSG_DONTWAIT | libc::MSG_CMSG_CLOEXEC; + #[cfg(not(target_os = "linux"))] + let flags = libc::MSG_DONTWAIT; + let count = unsafe { libc::recvmsg(control.as_raw_fd(), &mut message, flags) }; + if count < 0 + && matches!( + io::Error::last_os_error().kind(), + io::ErrorKind::Interrupted | io::ErrorKind::WouldBlock + ) + { + continue; + } + if count < 0 { + return Err(refused()); + } + let mut descriptors = Vec::new(); + let mut invalid = false; + // SAFETY: the kernel populated these headers within msg_controllen. + // Every SCM_RIGHTS integer transfers ownership once, even on refusal. + unsafe { + let mut header = libc::CMSG_FIRSTHDR(&message); + while !header.is_null() { + let length = (*header).cmsg_len as usize; + let base = libc::CMSG_LEN(0) as usize; + if length < base { + invalid = true; + break; + } + if (*header).cmsg_level == libc::SOL_SOCKET + && (*header).cmsg_type == libc::SCM_RIGHTS + { + invalid |= (length - base) % size_of::() != 0; + for index in 0..(length - base) / size_of::() { + let fd = libc::CMSG_DATA(header) + .cast::() + .add(index) + .read_unaligned(); + descriptors.push(OwnedFd::from_raw_fd(fd)); + } + } else { + invalid = true; + } + header = libc::CMSG_NXTHDR(&message, header); + } + } + for fd in &descriptors { + // SAFETY: OwnedFd keeps this descriptor alive; macOS requires an + // explicit CLOEXEC since it lacks MSG_CMSG_CLOEXEC. + if unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) } < 0 { + return Err(refused()); + } + } + if count != 1 || invalid || message.msg_flags & (libc::MSG_CTRUNC | libc::MSG_TRUNC) != 0 { + return Err(refused()); + } + match (tag, descriptors.len()) { + (0, 0) => return Ok(None), + (1, 1) => { + let socket = UnixStream::from(descriptors.pop().ok_or_else(refused)?); + // getsockname/getpeername reject files and non-Unix descriptors. + socket.local_addr().map_err(|_| refused())?; + socket.peer_addr().map_err(|_| refused())?; + let mut kind: libc::c_int = 0; + let mut length = size_of::() as libc::socklen_t; + // SAFETY: socket owns the fd and both output buffers have the + // exact writable sizes supplied to getsockopt. + if unsafe { + libc::getsockopt( + socket.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_TYPE, + (&mut kind as *mut libc::c_int).cast(), + &mut length, + ) + } != 0 + || kind != libc::SOCK_STREAM + || length as usize != size_of::() + { + return Err(refused()); + } + return Ok(Some(socket)); + } + _ => return Err(refused()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + io::{Read, Write}, + time::Duration, + }; + + #[test] + fn handoff_preserves_early_output_and_drops_sender_copy() { + let (sender, receiver) = UnixStream::pair().unwrap(); + let (session, mut engine) = UnixStream::pair().unwrap(); + let deadline = Instant::now() + Duration::from_secs(2); + engine.write_all(b"first output with upgrade").unwrap(); + send(&sender, Some(session), deadline).unwrap(); + let mut attached = receive(&receiver, deadline).unwrap().unwrap(); + assert_ne!( + unsafe { libc::fcntl(attached.as_raw_fd(), libc::F_GETFD) } & libc::FD_CLOEXEC, + 0 + ); + let mut early = [0; 25]; + attached.read_exact(&mut early).unwrap(); + assert_eq!(&early, b"first output with upgrade"); + drop(attached); + engine + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + assert_eq!(engine.read(&mut [0]).unwrap(), 0); + } + + #[test] + fn absent_descriptor_and_disconnect_are_distinct() { + let (sender, receiver) = UnixStream::pair().unwrap(); + let deadline = Instant::now() + Duration::from_secs(1); + send(&sender, None, deadline).unwrap(); + assert!(receive(&receiver, deadline).unwrap().is_none()); + drop(receiver); + let (session, mut engine) = UnixStream::pair().unwrap(); + assert_eq!( + send(&sender, Some(session), deadline).unwrap_err().code, + "session_cleanup_uncertain" + ); + engine + .set_read_timeout(Some(Duration::from_secs(1))) + .unwrap(); + assert_eq!(engine.read(&mut [0]).unwrap(), 0); + } + + #[test] + fn invalid_tag_and_missing_descriptor_refuse() { + for tag in [1, 2, 255] { + let (mut sender, receiver) = UnixStream::pair().unwrap(); + sender.write_all(&[tag]).unwrap(); + assert!(receive(&receiver, Instant::now() + Duration::from_secs(1)).is_err()); + } + } + #[test] + fn multiple_and_truncated_rights_close_every_received_copy() { + for count in [2usize, 200] { + let (sender, receiver) = UnixStream::pair().unwrap(); + let (session, mut engine) = UnixStream::pair().unwrap(); + let mut tag = 1u8; + let mut iov = libc::iovec { + iov_base: (&mut tag as *mut u8).cast(), + iov_len: 1, + }; + let mut ancillary = [0usize; 256]; + // SAFETY: fully sized, aligned ancillary storage; every payload int + // borrows session's live descriptor and sendmsg duplicates it. + unsafe { + let mut message: libc::msghdr = zeroed(); + message.msg_iov = &mut iov; + message.msg_iovlen = 1; + message.msg_control = ancillary.as_mut_ptr().cast(); + message.msg_controllen = libc::CMSG_SPACE((count * size_of::()) as u32) as _; + let header = libc::CMSG_FIRSTHDR(&message); + (*header).cmsg_level = libc::SOL_SOCKET; + (*header).cmsg_type = libc::SCM_RIGHTS; + (*header).cmsg_len = libc::CMSG_LEN((count * size_of::()) as u32) as _; + for index in 0..count { + libc::CMSG_DATA(header) + .cast::() + .add(index) + .write_unaligned(session.as_raw_fd()); + } + assert_eq!( + libc::sendmsg(sender.as_raw_fd(), &message, libc::MSG_NOSIGNAL), + 1 + ); + } + drop(session); + assert_eq!( + receive(&receiver, Instant::now() + Duration::from_secs(1)) + .unwrap_err() + .code, + "session_cleanup_uncertain" + ); + engine + .set_read_timeout(Some(Duration::from_secs(1))) + .unwrap(); + assert_eq!( + engine.read(&mut [0]).unwrap(), + 0, + "all received rights must close" + ); + } + } + #[test] + fn datagram_is_not_an_engine_stream() { + let (sender, receiver) = UnixStream::pair().unwrap(); + let (datagram, _peer) = std::os::unix::net::UnixDatagram::pair().unwrap(); + let fd: OwnedFd = datagram.into(); + let deadline = Instant::now() + Duration::from_secs(1); + send(&sender, Some(UnixStream::from(fd)), deadline).unwrap(); + assert!(receive(&receiver, deadline).is_err()); + } +} diff --git a/packages/runtime-core/src/provider/interactive_session.rs b/packages/runtime-core/src/provider/interactive_session.rs new file mode 100644 index 000000000..58895e2e9 --- /dev/null +++ b/packages/runtime-core/src/provider/interactive_session.rs @@ -0,0 +1,719 @@ +//! Value-free write-ahead interactive intents. They authorize no replay. Even a +//! completed exec may have descendants, so only exact container absence retires +//! these records; transport EOF and a changed VM boot are never cleanup proof. +use super::{engine::log_follow::valid_timestamp, environment_recovery, state}; +use crate::{Candidate, CandidateError}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeMap, + fs::{self, File}, + io::Read, + os::unix::fs::MetadataExt, + path::PathBuf, + time::{Duration, Instant}, +}; + +const LIMIT: usize = 1024; +fn refused() -> CandidateError { + CandidateError::new( + "session_receipt_refused", + "Interactive session evidence is unsafe, incomplete or changed; no start was replayed and all evidence was retained.", + ) +} +fn hex(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Backend { + Authored, + Adopted, +} + +/// Exact admitted incarnation. Contains identifiers only, never argv, paths or +/// environment values. The frontend already owns the project-down remedy. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Binding { + pub backend: Backend, + pub namespace: String, + pub run: String, + pub owner: String, + pub boot: String, + pub container: String, + pub generation: String, + pub service: String, +} +impl Binding { + pub fn valid(&self) -> bool { + hex(&self.namespace, 64) + && hex(&self.run, 32) + && hex(&self.owner, 32) + && environment_recovery::uuid(&self.boot) + && hex(&self.container, 64) + && valid_timestamp(&self.generation) + && !self.service.is_empty() + && self.service.len() <= 128 + && self + .service + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)) + } +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Receipt { + version: u8, + pub session: String, + pub binding: Binding, + pub exec: Option, +} +impl Receipt { + pub fn valid(&self) -> bool { + self.version == 1 + && environment_recovery::uuid(&self.session) + && self.binding.valid() + && self.exec.as_deref().is_none_or(|id| hex(id, 64)) + } +} + +struct Store { + root: PathBuf, + identity: (u64, u64), + lock: state::Lock, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct StoppedBoot { + owner: String, + boot: String, +} +impl StoppedBoot { + fn name(&self) -> Result { + if !hex(&self.owner, 32) || !environment_recovery::uuid(&self.boot) { + return Err(refused()); + } + Ok(format!("stopped-{}-{}.json", self.owner, self.boot)) + } + fn covers(&self, binding: &Binding) -> bool { + self.owner == binding.owner && self.boot == binding.boot + } +} +impl Store { + fn open(candidate: &Candidate, create: bool) -> Result, CandidateError> { + let root = candidate.state_root.join("run/exec-sessions"); + if !create { + match fs::symlink_metadata(&root) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(refused()), + Ok(_) => (), + } + } + let deadline = Instant::now() + Duration::from_secs(2); + let lock = loop { + let attempt = if create { + state::Lock::acquire(&root) + } else { + state::Lock::acquire_existing(&root) + }; + match attempt { + Ok(lock) => break lock, + Err(error) if error.code == "provider_busy" && Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(10)) + } + Err(error) if error.code == "provider_busy" => { + return Err(CandidateError::new( + "session_receipt_busy", + "Another interactive admission is updating session evidence; nothing was replayed.", + )); + } + Err(_) => return Err(refused()), + } + }; + let metadata = fs::symlink_metadata(&root).map_err(|_| refused())?; + let value = Self { + root, + identity: (metadata.dev(), metadata.ino()), + lock, + }; + value.check()?; + if create { + // Persist newly created directory entries as well as their contents. + for parent in value.root.ancestors().skip(1) { + File::open(parent) + .and_then(|f| f.sync_all()) + .map_err(|_| refused())?; + if parent == candidate.state_root.parent().ok_or_else(refused)? { + break; + } + } + } + Ok(Some(value)) + } + fn check(&self) -> Result<(), CandidateError> { + self.lock.verify_path(&self.root).map_err(|_| refused())?; + let metadata = fs::symlink_metadata(&self.root).map_err(|_| refused())?; + if !metadata.is_dir() || (metadata.dev(), metadata.ino()) != self.identity { + return Err(refused()); + } + Ok(()) + } + fn path(&self, session: &str, exec: bool) -> PathBuf { + self.root.join(format!( + "{session}.{}.json", + if exec { "exec" } else { "intent" } + )) + } + fn write(&self, receipt: &Receipt) -> Result<(), CandidateError> { + if !receipt.valid() { + return Err(refused()); + } + self.check()?; + let path = self.path(&receipt.session, receipt.exec.is_some()); + let absent = || { + self.check()?; + match fs::symlink_metadata(&path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + _ => Err(refused()), + } + }; + absent()?; + state::write_before_rename(&path, receipt, &absent).map_err(|_| refused()) + } + fn snapshot(&self) -> Result<(Vec, Vec), CandidateError> { + self.check()?; + let mut intents = BTreeMap::new(); + let mut execs = BTreeMap::new(); + let mut stopped = Vec::new(); + for (index, entry) in fs::read_dir(&self.root).map_err(|_| refused())?.enumerate() { + if index > LIMIT * 4 { + return Err(refused()); + } + let entry = entry.map_err(|_| refused())?; + let name = entry.file_name(); + let name = name.to_str().ok_or_else(refused)?; + if name == "operation.lock" { + continue; + } + if name.starts_with("stopped-") && name.ends_with(".json") { + let proof: StoppedBoot = + state::read_bounded(&entry.path(), 4096).map_err(|_| refused())?; + if proof.name()? != name { + return Err(refused()); + } + stopped.push(proof); + continue; + } + if let Some(base) = name.strip_suffix(".pending") { + let receipt_name = base.rsplit_once('.').is_some_and(|(session, kind)| { + environment_recovery::uuid(session) && ["intent", "exec"].contains(&kind) + }); + let proof_name = base.strip_prefix("stopped-").is_some_and(|value| { + value.is_ascii() + && value.len() == 69 + && hex(&value[..32], 32) + && value.as_bytes()[32] == b'-' + && environment_recovery::uuid(&value[33..]) + }); + if !receipt_name && !proof_name { + return Err(refused()); + } + let metadata = fs::symlink_metadata(entry.path()).map_err(|_| refused())?; + // These are our unpublished value-free writes. No effect may + // follow until the final rename and directory fsync return. + if !metadata.is_file() + || metadata.nlink() != 1 + || metadata.mode() & 0o7777 != 0o600 + || metadata.uid() != unsafe { libc::geteuid() } + { + return Err(refused()); + } + // Observation never deletes unpublished evidence. Mutation + // paths prune these files under the lock before publishing. + continue; + } + let receipt: Receipt = + state::read_bounded(&entry.path(), 4096).map_err(|_| refused())?; + if !receipt.valid() + || entry.path() != self.path(&receipt.session, receipt.exec.is_some()) + { + return Err(refused()); + } + let map = if receipt.exec.is_some() { + &mut execs + } else { + &mut intents + }; + if map.insert(receipt.session.clone(), receipt).is_some() { + return Err(refused()); + } + } + if intents.len() > LIMIT { + return Err(refused()); + } + for (session, execution) in execs { + let intent = intents.get_mut(&session).ok_or_else(refused)?; + if intent.binding != execution.binding { + return Err(refused()); + } + *intent = execution; + } + self.check()?; + Ok((intents.into_values().collect(), stopped)) + } + fn list(&self) -> Result, CandidateError> { + Ok(self.snapshot()?.0) + } + fn prune_pending(&self) -> Result<(), CandidateError> { + self.snapshot()?; // Validate every name and file before mutation. + for entry in fs::read_dir(&self.root).map_err(|_| refused())? { + let entry = entry.map_err(|_| refused())?; + if entry + .file_name() + .to_str() + .is_some_and(|s| s.ends_with(".pending")) + { + self.check()?; + fs::remove_file(entry.path()).map_err(|_| refused())?; + } + } + File::open(&self.root) + .and_then(|f| f.sync_all()) + .map_err(|_| refused()) + } +} + +/// Only a newly fsynced intent can be advanced to an exec ID. Loading receipts +/// returns data, never this capability. A crashed create/start is never replayed. +pub struct Intent { + receipt: Receipt, +} +impl Intent { + pub fn receipt(&self) -> &Receipt { + &self.receipt + } + /// Only live admission may withdraw a definitely undispatched intent. + /// Loading records never recreates this capability. + pub fn withdraw(self, candidate: &Candidate) -> Result<(), CandidateError> { + let store = Store::open(candidate, false)?.ok_or_else(refused)?; + if self.receipt.exec.is_some() || !store.list()?.contains(&self.receipt) { + return Err(refused()); + } + store.check()?; + fs::remove_file(store.path(&self.receipt.session, false)).map_err(|_| refused())?; + File::open(&store.root) + .and_then(|f| f.sync_all()) + .map_err(|_| refused()) + } + pub fn record_exec( + mut self, + candidate: &Candidate, + exec: &str, + ) -> Result { + if !hex(exec, 64) { + return Err(refused()); + } + let store = Store::open(candidate, false)?.ok_or_else(refused)?; + if !store.list()?.iter().any(|r| r == &self.receipt) { + return Err(refused()); + } + self.receipt.exec = Some(exec.into()); + store.write(&self.receipt)?; + Ok(self.receipt) + } +} + +pub fn admit(candidate: &Candidate, binding: Binding) -> Result { + if !binding.valid() { + return Err(refused()); + } + let store = Store::open(candidate, true)?.ok_or_else(refused)?; + store.prune_pending()?; + if store.list()?.len() >= LIMIT { + return Err(CandidateError::new( + "session_receipt_limit", + "The native home has 1024 retained interactive sessions. Run hack down for a project with retained sessions before opening another session.", + )); + } + let mut random = [0u8; 16]; + File::open("/dev/urandom") + .and_then(|mut f| f.read_exact(&mut random)) + .map_err(|_| refused())?; + random[6] = (random[6] & 0x0f) | 0x40; + random[8] = (random[8] & 0x3f) | 0x80; + let text: String = random.iter().map(|b| format!("{b:02x}")).collect(); + let session = format!( + "{}-{}-{}-{}-{}", + &text[..8], + &text[8..12], + &text[12..16], + &text[16..20], + &text[20..] + ); + let receipt = Receipt { + version: 1, + session, + binding, + exec: None, + }; + store.write(&receipt)?; + Ok(Intent { receipt }) +} +pub fn retained(candidate: &Candidate) -> Result, CandidateError> { + Store::open(candidate, false)?.map_or(Ok(Vec::new()), |s| s.list()) +} + +pub(crate) enum RetirementProof { + Unknown, + ContainerAbsent, + BootStopped, +} + +/// Called only while lifecycle holds the released VM's lock, after process, +/// disk-handle and socket absence are proven. Persist before changing the owner +/// phase: a crash or subsequent boot cannot erase the cleanup proof. +#[cfg(target_os = "macos")] +pub(crate) fn after_boot_stopped(candidate: &Candidate, owner: &state::Owner) { + let result = (|| { + let Some(boot) = &owner.guest_boot_id else { + return Ok(()); + }; + let Some(store) = Store::open(candidate, false)? else { + return Ok(()); + }; + store.prune_pending()?; + let (receipts, _) = store.snapshot()?; + let proof = StoppedBoot { + owner: owner.token.clone(), + boot: boot.clone(), + }; + if !receipts.iter().any(|r| proof.covers(&r.binding)) { + return Ok(()); + } + state::write_before_rename(&store.root.join(proof.name()?), &proof, &|| store.check())?; + drop(store); + reconcile(candidate, &|_| Ok(RetirementProof::Unknown))?; + Ok::<_, CandidateError>(()) + })(); + if result.is_err() { + cleanup_warning(); + } +} + +#[cfg(target_os = "macos")] +fn cleanup_warning() { + eprintln!( + "session_cleanup_uncertain: interactive receipts were retained; container cleanup was not rolled back. Retry hack down for this project and instance." + ); +} + +/// Only exact same-boot absence or the existing clean-stop receipt retires a +/// session. A new boot cannot establish absence in an earlier boot's Engine. +#[cfg(target_os = "macos")] +pub(crate) fn reconcile_observed(candidate: &Candidate) -> Result { + if retained(candidate)?.is_empty() { + return Ok(0); + } + let owner = state::Owner::load(candidate)?; + let observer = super::engine::Observer::connect(candidate); + let deadline = Instant::now() + Duration::from_secs(2); + reconcile(candidate, &|binding| { + if binding.owner != owner.token { + return Ok(RetirementProof::Unknown); + } + if owner.phase == "stopped" + && owner + .clean_stop + .as_ref() + .is_some_and(|stop| stop.guest_boot_id == binding.boot) + { + return Ok(RetirementProof::BootStopped); + } + let observer = observer.as_ref().map_err(|_| refused())?; + if Instant::now() >= deadline { + return Ok(RetirementProof::Unknown); + } + if observer.incarnation() != binding.owner || observer.guest().boot_id()? != binding.boot { + return Ok(RetirementProof::Unknown); + } + match observer.get_until( + &format!("/v1.53/containers/{}/json", binding.container), + deadline, + ) { + Err(error) if error.code == "engine_not_found" => Ok(RetirementProof::ContainerAbsent), + Ok(_) => Ok(RetirementProof::Unknown), + Err(error) => Err(error), + } + }) +} + +/// Bookkeeping never prevents the container cleanup that resolves uncertainty. +#[cfg(target_os = "macos")] +pub(crate) fn after_cleanup(candidate: &Candidate) { + if reconcile_observed(candidate).is_err() { + cleanup_warning(); + } +} + +/// Caller supplies exact absence or established stopped-boot proof for each +/// binding. Both must cover its boot and owner, never a new-boot 404. This helper +/// performs no Engine effects and does not turn malformed receipts into absence. +pub(crate) fn reconcile( + candidate: &Candidate, + proof: &dyn Fn(&Binding) -> Result, +) -> Result { + let Some(store) = Store::open(candidate, false)? else { + return Ok(0); + }; + store.prune_pending()?; + let (receipts, stopped) = store.snapshot()?; + drop(store); + let deadline = Instant::now() + Duration::from_secs(4); + let mut count = 0; + for receipt in receipts { + if Instant::now() >= deadline { + break; + } + if !stopped.iter().any(|p| p.covers(&receipt.binding)) + && matches!(proof(&receipt.binding)?, RetirementProof::Unknown) + { + continue; + } + // Engine observation runs with no journal lock held. Revalidate after + // reacquiring it, so another admission/down can progress meanwhile. + let Some(store) = Store::open(candidate, false)? else { + continue; + }; + let current = store.list()?; + if !current.contains(&receipt) { + continue; + } + store.check()?; + if receipt.exec.is_some() { + fs::remove_file(store.path(&receipt.session, true)).map_err(|_| refused())?; + File::open(&store.root) + .and_then(|f| f.sync_all()) + .map_err(|_| refused())?; + } + // Exec removed first: a crash here retains a valid intent. It still + // requires the absence proof on a later reconciliation. + fs::remove_file(store.path(&receipt.session, false)).map_err(|_| refused())?; + File::open(&store.root) + .and_then(|f| f.sync_all()) + .map_err(|_| refused())?; + store.check()?; + count += 1; + } + // Keep a stopped-boot proof across partial reconciliation. Remove it only + // after every bound receipt has gone; admission to that boot is fenced by + // lifecycle's VM lock and the admission freshness checks. + if !stopped.is_empty() { + if let Some(store) = Store::open(candidate, false)? { + let current = store.list()?; + for stop in stopped { + if !current.iter().any(|r| stop.covers(&r.binding)) { + store.check()?; + match fs::remove_file(store.root.join(stop.name()?)) { + Ok(()) => (), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => (), + Err(_) => return Err(refused()), + } + } + } + File::open(&store.root) + .and_then(|f| f.sync_all()) + .map_err(|_| refused())?; + } + } + Ok(count) +} + +#[cfg(test)] +mod tests { + use super::*; + fn fixture() -> (PathBuf, Candidate, Binding) { + let root = crate::private_test_root(&std::env::temp_dir(), "interactive-session"); + let candidate = Candidate::discover(&root).unwrap(); + let binding = Binding { + backend: Backend::Authored, + namespace: "a".repeat(64), + run: "b".repeat(32), + owner: "c".repeat(32), + boot: "00000000-0000-4000-8000-000000000001".into(), + container: "d".repeat(64), + generation: "2026-11-12T01:02:03.123456789Z".into(), + service: "web".into(), + }; + (root, candidate, binding) + } + #[test] + fn create_and_start_crash_windows_retain_evidence_without_replay() { + let (root, candidate, binding) = fixture(); + let intent = admit(&candidate, binding.clone()).unwrap(); + assert_eq!( + retained(&candidate).unwrap(), + vec![intent.receipt().clone()] + ); + drop(intent); // Crash before create or before recording its response. + let second = admit(&candidate, binding.clone()).unwrap(); + let receipt = second.record_exec(&candidate, &"e".repeat(64)).unwrap(); + assert_eq!(retained(&candidate).unwrap().len(), 2); + assert!(retained(&candidate).unwrap().contains(&receipt)); + assert_eq!( + reconcile(&candidate, &|_| Ok(RetirementProof::Unknown)).unwrap(), + 0 + ); // EOF/exit is not proof. + assert_eq!( + reconcile(&candidate, &|b| { + assert_eq!(b, &binding); + Ok(RetirementProof::ContainerAbsent) + }) + .unwrap(), + 2 + ); + assert!(retained(&candidate).unwrap().is_empty()); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn corrupt_or_foreign_evidence_refuses_without_deletion() { + let (root, candidate, binding) = fixture(); + let intent = admit(&candidate, binding.clone()).unwrap(); + let path = candidate + .state_root + .join("run/exec-sessions") + .join(format!("{}.intent.json", intent.receipt.session)); + fs::write(&path, b"{").unwrap(); + assert_eq!( + admit(&candidate, binding).err().unwrap().code, + "session_receipt_refused" + ); + assert!(reconcile(&candidate, &|_| Ok(RetirementProof::BootStopped)).is_err()); + assert_eq!(fs::read(path).unwrap(), b"{"); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn observation_alone_does_not_create_state() { + let (root, candidate, _) = fixture(); + assert!(retained(&candidate).unwrap().is_empty()); + assert_eq!( + reconcile(&candidate, &|_| panic!("no receipts")).unwrap(), + 0 + ); + assert!(!candidate.state_root.exists()); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn known_undispatched_admission_can_withdraw_its_own_intent() { + let (root, candidate, binding) = fixture(); + let first = admit(&candidate, binding.clone()).unwrap(); + let second = admit(&candidate, binding).unwrap(); + let retained_id = second.receipt().session.clone(); + first.withdraw(&candidate).unwrap(); + let receipts = retained(&candidate).unwrap(); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0].session, retained_id); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn full_home_names_capacity_without_discarding_existing_sessions() { + let (root, candidate, binding) = fixture(); + let store = Store::open(&candidate, true).unwrap().unwrap(); + for index in 0..LIMIT { + store + .write(&Receipt { + version: 1, + session: format!("00000000-0000-4000-8000-{index:012x}"), + binding: binding.clone(), + exec: None, + }) + .unwrap(); + } + drop(store); + let error = admit(&candidate, binding).err().unwrap(); + assert_eq!(error.code, "session_receipt_limit"); + assert!(error.message.to_string().contains("hack down")); + assert_eq!(retained(&candidate).unwrap().len(), LIMIT); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn unpublished_writes_are_observed_without_mutation_and_pruned_before_admission() { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let (root, candidate, binding) = fixture(); + let intent = admit(&candidate, binding.clone()).unwrap(); + let store = Store::open(&candidate, false).unwrap().unwrap(); + let path = store + .path(&intent.receipt.session, true) + .with_extension("pending"); + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + .unwrap(); + file.write_all(b"{torn").unwrap(); + file.sync_all().unwrap(); + drop(store); + assert_eq!(retained(&candidate).unwrap(), vec![intent.receipt.clone()]); + assert_eq!(fs::read(&path).unwrap(), b"{torn"); + admit(&candidate, binding).unwrap(); + assert!(!path.exists()); + assert_eq!(retained(&candidate).unwrap().len(), 2); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn stopped_boot_proof_survives_owner_replacement_and_is_exact() { + let (root, candidate, binding) = fixture(); + admit(&candidate, binding.clone()) + .unwrap() + .record_exec(&candidate, &"e".repeat(64)) + .unwrap(); + let mut later = binding.clone(); + later.boot = "00000000-0000-4000-8000-000000000002".into(); + admit(&candidate, later.clone()).unwrap(); + let mut foreign = binding.clone(); + foreign.owner = "f".repeat(32); + admit(&candidate, foreign).unwrap(); + let store = Store::open(&candidate, false).unwrap().unwrap(); + let proof = StoppedBoot { + owner: binding.owner, + boot: binding.boot, + }; + let path = store.root.join(proof.name().unwrap()); + state::write_before_rename(&path, &proof, &|| store.check()).unwrap(); + drop(store); // Crash after proof publication, before receipt retirement. + assert_eq!( + reconcile(&candidate, &|_| Ok(RetirementProof::Unknown)).unwrap(), + 1 + ); + let remaining = retained(&candidate).unwrap(); + assert_eq!(remaining.len(), 2); + assert!(remaining.iter().any(|r| r.binding == later)); + assert!(!path.exists()); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn observation_releases_lock_and_revalidates_before_retirement() { + let (root, candidate, binding) = fixture(); + let intent = std::cell::RefCell::new(Some(admit(&candidate, binding).unwrap())); + assert_eq!( + reconcile(&candidate, &|_| { + intent + .borrow_mut() + .take() + .unwrap() + .record_exec(&candidate, &"e".repeat(64)) + .unwrap(); + Ok(RetirementProof::ContainerAbsent) + }) + .unwrap(), + 0 + ); + assert!(retained(&candidate).unwrap()[0].exec.is_some()); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/packages/runtime-core/src/provider/interactive_terminal.rs b/packages/runtime-core/src/provider/interactive_terminal.rs new file mode 100644 index 000000000..0dda5ffd3 --- /dev/null +++ b/packages/runtime-core/src/provider/interactive_terminal.rs @@ -0,0 +1,593 @@ +//! Dedicated native-child terminal ownership. Private input arrives over fd 3; +//! it stays open afterward as a lifetime leash, including while the PTY is idle. +use crate::CandidateError; +use std::{ + collections::VecDeque, + ffi::CStr, + fs::{File, OpenOptions}, + io::{self, Read, Write}, + os::{ + fd::{AsRawFd, OwnedFd}, + unix::{ + ffi::OsStrExt, + fs::{FileTypeExt, MetadataExt, OpenOptionsExt}, + net::UnixStream, + }, + }, + path::Path, + sync::atomic::{AtomicBool, AtomicI32, AtomicU32, Ordering}, + time::{Duration, Instant}, +}; +use zeroize::Zeroizing; + +const QUEUE: usize = 64 * 1024; + +/// One bounded resize request at a time, with a single last-value mailbox. +/// Network inspection never runs on the terminal I/O thread. The caller's +/// resize operation must have its own deadline; scope exit joins it. +pub(crate) fn with_resize_worker( + mut resize: impl FnMut(u16, u16) + Send, + session: impl FnOnce(&dyn Fn(u16, u16)) -> R, +) -> R { + let pending = AtomicU32::new(0); + let done = AtomicBool::new(false); + struct Stop<'a>(&'a AtomicBool); + impl Drop for Stop<'_> { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } + } + std::thread::scope(|scope| { + let pending = &pending; + let done = &done; + scope.spawn(move || { + let mut next = Instant::now(); + while !done.load(Ordering::Acquire) { + if Instant::now() >= next { + let value = pending.swap(0, Ordering::AcqRel); + if value != 0 { + resize((value >> 16) as u16, value as u16); + next = Instant::now() + Duration::from_millis(250); + } + } + std::thread::sleep(Duration::from_millis(25)); + } + }); + let _stop = Stop(done); + session(&|rows, cols| { + pending.store((u32::from(rows) << 16) | u32::from(cols), Ordering::Release); + }) + }) +} +static OWNED: AtomicBool = AtomicBool::new(false); +static SIGNAL: AtomicI32 = AtomicI32::new(0); +extern "C" fn interrupted(signal: i32) { + SIGNAL.store(signal, Ordering::Release); +} +fn refused() -> CandidateError { + CandidateError::new( + "interactive_terminal_refused", + "Interactive exec requires the foreground terminal and a private frontend lifetime pipe.", + ) +} +fn lost() -> CandidateError { + CandidateError::new( + "session_cleanup_uncertain", + "Interactive transport ended without complete process cleanup proof; its session receipt was retained.", + ) +} + +struct Signals { + previous: Vec<(i32, libc::sigaction)>, +} +impl Signals { + fn new() -> Result { + if OWNED + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err(refused()); + } + let mut guard = Self { + previous: Vec::new(), + }; + SIGNAL.store(0, Ordering::Release); + for signal in [ + libc::SIGINT, + libc::SIGTERM, + libc::SIGHUP, + libc::SIGQUIT, + libc::SIGTSTP, + ] { + // SAFETY: these C structs have valid zero representations; the + // handler touches only a lock-free atomic and retains no pointers. + let mut action: libc::sigaction = unsafe { std::mem::zeroed() }; + let mut previous: libc::sigaction = unsafe { std::mem::zeroed() }; + action.sa_sigaction = interrupted as *const () as usize; + unsafe { + libc::sigemptyset(&mut action.sa_mask); + } + if unsafe { libc::sigaction(signal, &action, &mut previous) } != 0 { + return Err(refused()); + } + guard.previous.push((signal, previous)); + } + Ok(guard) + } +} +impl Drop for Signals { + fn drop(&mut self) { + for (signal, previous) in &self.previous { + // SAFETY: restore the exact disposition captured by this guard. + unsafe { + libc::sigaction(*signal, previous, std::ptr::null_mut()); + } + } + OWNED.store(false, Ordering::Release); + } +} + +pub struct Leash { + file: File, +} +#[cfg(any(feature = "native-config-plan", test))] +pub(crate) struct ControlWatch { + stop: std::sync::Arc, + thread: Option>, +} +#[cfg(any(feature = "native-config-plan", test))] +impl Drop for ControlWatch { + fn drop(&mut self) { + self.stop.store(true, Ordering::Release); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} +impl Leash { + /// Close an admission connection when the frontend disappears. This keeps + /// the live owner's existing client-disconnect guard effective while the + /// native child is waiting for the bounded control reply. + #[cfg(any(feature = "native-config-plan", test))] + pub(crate) fn watch_control( + &self, + stream: &UnixStream, + ) -> Result { + self.check()?; + let leash = Self { + file: self.file.try_clone().map_err(|_| lost())?, + }; + let stream = stream.try_clone().map_err(|_| lost())?; + let stop = std::sync::Arc::new(AtomicBool::new(false)); + let done = stop.clone(); + let thread = std::thread::Builder::new() + .name("interactive-admission-leash".into()) + .spawn(move || { + while !done.load(Ordering::Acquire) { + if leash.check().is_err() { + let _ = stream.shutdown(std::net::Shutdown::Both); + break; + } + let mut fd = libc::pollfd { + fd: leash.file.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: the owned leash keeps its fd and this poll entry live. + unsafe { + libc::poll(&mut fd, 1, 50); + } + } + }) + .map_err(|_| lost())?; + Ok(ControlWatch { + stop, + thread: Some(thread), + }) + } + /// The length prefix separates the private payload from the still-open + /// lifetime pipe. No user input is ever carried on this descriptor. + pub fn receive(fd: OwnedFd) -> Result<(Self, Zeroizing>), CandidateError> { + let file = File::from(fd); + super::private_input::validate_duplex(&file)?; + // SAFETY: file retains this fd, and the dedicated child owns its read end. + let flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFL) }; + if flags < 0 + || unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 + { + return Err(refused()); + } + let mut leash = Self { file }; + let deadline = Instant::now() + Duration::from_secs(5); + let mut length = [0; 4]; + leash.read_until(&mut length, deadline)?; + let length = u32::from_be_bytes(length) as usize; + if length > super::private_input::MAX_RECEIVE_BYTES { + return Err(refused()); + } + let mut payload = Zeroizing::new(vec![0; length]); + leash.read_until(&mut payload, deadline)?; + leash.check()?; + Ok((leash, payload)) + } + fn read_until( + &mut self, + mut bytes: &mut [u8], + deadline: Instant, + ) -> Result<(), CandidateError> { + while !bytes.is_empty() { + if SIGNAL.load(Ordering::Acquire) != 0 || Instant::now() >= deadline { + return Err(lost()); + } + let mut fd = libc::pollfd { + fd: self.file.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: a single live poll entry, bounded wait. + if unsafe { libc::poll(&mut fd, 1, 50) } < 0 + && io::Error::last_os_error().kind() != io::ErrorKind::Interrupted + { + return Err(lost()); + } + match self.file.read(bytes) { + Ok(0) => return Err(lost()), + Ok(n) => bytes = &mut bytes[n..], + Err(e) if retry(&e) => (), + Err(_) => return Err(lost()), + } + } + Ok(()) + } + pub fn check(&self) -> Result<(), CandidateError> { + if SIGNAL.load(Ordering::Acquire) != 0 { + return Err(lost()); + } + let mut file = &self.file; + match file.read(&mut [0]) { + Err(e) if retry(&e) => Ok(()), + _ => Err(lost()), // EOF, unexpected bytes, or a broken pipe. + } + } +} + +pub struct Terminal { + file: File, + saved: libc::termios, + raw: bool, + _signals: Signals, +} +impl Terminal { + /// Open the same terminal through a separate file description so our + /// nonblocking flags cannot alter the frontend's inherited descriptors. + pub fn open() -> Result { + let signals = Signals::new()?; + // SAFETY: isatty/getpgrp/tcgetpgrp are read-only kernel queries. + if unsafe { + libc::isatty(0) != 1 || libc::isatty(1) != 1 || libc::tcgetpgrp(0) != libc::getpgrp() + } { + return Err(refused()); + } + let mut name = [0i8; 1024]; + // SAFETY: ttyname_r writes at most the provided buffer length. + if unsafe { libc::ttyname_r(0, name.as_mut_ptr(), name.len()) } != 0 { + return Err(refused()); + } + let path = Path::new(std::ffi::OsStr::from_bytes( + unsafe { CStr::from_ptr(name.as_ptr()) }.to_bytes(), + )); + let file = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NOCTTY | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(path) + .map_err(|_| refused())?; + // SAFETY: fstat initializes input; fd 0 is borrowed, never closed here. + let mut input: libc::stat = unsafe { std::mem::zeroed() }; + let mut output: libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { libc::fstat(0, &mut input) } != 0 + || unsafe { libc::fstat(1, &mut output) } != 0 + || input.st_rdev != output.st_rdev + { + return Err(refused()); + } + let metadata = file.metadata().map_err(|_| refused())?; + if !metadata.file_type().is_char_device() || metadata.rdev() != input.st_rdev as u64 { + return Err(refused()); + } + let mut saved: libc::termios = unsafe { std::mem::zeroed() }; + if unsafe { libc::tcgetattr(file.as_raw_fd(), &mut saved) } != 0 { + return Err(refused()); + } + Ok(Self { + file, + saved, + raw: false, + _signals: signals, + }) + } + pub fn size(&self) -> Result<(u16, u16), CandidateError> { + let mut size: libc::winsize = unsafe { std::mem::zeroed() }; + // SAFETY: size is a writable winsize and this terminal is retained. + if unsafe { libc::ioctl(self.file.as_raw_fd(), libc::TIOCGWINSZ, &mut size) } != 0 { + return Err(refused()); + } + Ok((size.ws_row.max(1), size.ws_col.max(1))) + } + pub fn restore(&mut self) -> Result<(), CandidateError> { + if self.raw { + // SAFETY: saved came from this terminal; no pointers are retained. + if unsafe { libc::tcsetattr(self.file.as_raw_fd(), libc::TCSANOW, &self.saved) } != 0 { + return Err(CandidateError::new( + "interactive_terminal_restore_failed", + "Terminal restoration failed; run stty sane in this terminal.", + )); + } + self.raw = false; + } + Ok(()) + } + /// Called only after the Engine attach and any private-env ACK. Queues are + /// independently bounded; a large paste cannot block output or the leash. + pub fn relay( + &mut self, + stream: &mut UnixStream, + leash: &Leash, + resize: &mut dyn FnMut(u16, u16), + ) -> Result<(), CandidateError> { + leash.check()?; + let mut raw = self.saved; + // SAFETY: raw is initialized termios for this exact retained terminal. + unsafe { + libc::cfmakeraw(&mut raw); + } + if unsafe { libc::tcsetattr(self.file.as_raw_fd(), libc::TCSANOW, &raw) } != 0 { + return Err(refused()); + } + self.raw = true; + stream.set_nonblocking(true).map_err(|_| lost())?; + let result = self.copy(stream, leash, resize); + self.restore()?; + result + } + fn copy( + &mut self, + stream: &mut UnixStream, + leash: &Leash, + resize: &mut dyn FnMut(u16, u16), + ) -> Result<(), CandidateError> { + let mut input = VecDeque::with_capacity(QUEUE); + let mut output = VecDeque::with_capacity(QUEUE); + let mut eof = false; + let mut size = None; + loop { + leash.check()?; + if let Ok(current) = self.size() { + if size != Some(current) { + resize(current.0, current.1); + size = Some(current); + } + } + if eof && output.is_empty() { + return Ok(()); + } + let mut fds = [ + libc::pollfd { + fd: self.file.as_raw_fd(), + events: (if !eof && input.len() < QUEUE { + libc::POLLIN + } else { + 0 + }) | if output.is_empty() { 0 } else { libc::POLLOUT }, + revents: 0, + }, + libc::pollfd { + fd: stream.as_raw_fd(), + events: (if !eof && output.len() < QUEUE { + libc::POLLIN + } else { + 0 + }) | if input.is_empty() || eof { + 0 + } else { + libc::POLLOUT + }, + revents: 0, + }, + libc::pollfd { + fd: leash.file.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }, + ]; + // SAFETY: exactly three initialized poll entries are writable here. + if unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as _, 100) } < 0 { + if io::Error::last_os_error().kind() == io::ErrorKind::Interrupted { + continue; + } + return Err(lost()); + } + leash.check()?; + if fds[0].revents & (libc::POLLHUP | libc::POLLERR | libc::POLLNVAL) != 0 { + return Err(lost()); + } + if !eof + && fds[0].revents & libc::POLLIN != 0 + && !read_queue(&mut self.file, &mut input)? + { + return Err(lost()); + } + if !eof + && fds[1].revents & (libc::POLLIN | libc::POLLHUP | libc::POLLERR) != 0 + && output.len() < QUEUE + { + eof = !read_queue(stream, &mut output)?; + } + if fds[1].revents & libc::POLLNVAL != 0 { + return Err(lost()); + } + if fds[0].revents & libc::POLLOUT != 0 { + write_queue(&mut self.file, &mut output)?; + } + if !eof && fds[1].revents & libc::POLLOUT != 0 { + write_queue(stream, &mut input)?; + } + } + } +} +impl Drop for Terminal { + fn drop(&mut self) { + let _ = self.restore(); + } +} +fn retry(error: &io::Error) -> bool { + matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted + ) +} +fn read_queue(reader: &mut impl Read, queue: &mut VecDeque) -> Result { + let mut bytes = [0; 4096]; + let limit = (QUEUE - queue.len()).min(bytes.len()); + if limit == 0 { + return Ok(true); + } + match reader.read(&mut bytes[..limit]) { + Ok(0) => Ok(false), + Ok(n) => { + queue.extend(&bytes[..n]); + Ok(true) + } + Err(e) if retry(&e) => Ok(true), + Err(_) => Err(lost()), + } +} +fn write_queue(writer: &mut impl Write, queue: &mut VecDeque) -> Result<(), CandidateError> { + let bytes = queue.as_slices().0; + if bytes.is_empty() { + return Ok(()); + } + match writer.write(bytes) { + Ok(0) => Err(lost()), + Ok(n) => { + queue.drain(..n); + Ok(()) + } + Err(e) if retry(&e) => Ok(()), + Err(_) => Err(lost()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::fd::FromRawFd; + + // Re-exec this one test so process-wide terminal and signal ownership cannot + // affect the parallel Rust test runner. Only the PTY harness sets this mode. + #[test] + fn child_session() { + let Ok(mode) = std::env::var("HACK_INTERACTIVE_CHILD") else { + return; + }; + let control: i32 = std::env::var("HACK_INTERACTIVE_CONTROL") + .unwrap() + .parse() + .unwrap(); + let engine: i32 = std::env::var("HACK_INTERACTIVE_ENGINE") + .unwrap() + .parse() + .unwrap(); + assert!(control > 2 && engine > 2 && control != engine); + // SAFETY: the harness transferred these distinct inherited descriptors + // to this dedicated process; neither has another Rust owner. + let control = unsafe { OwnedFd::from_raw_fd(control) }; + let mut report = File::from(control.try_clone().unwrap()); + let mut engine = unsafe { UnixStream::from_raw_fd(engine) }; + let mut terminal = Terminal::open().unwrap(); + let (leash, payload) = Leash::receive(control).unwrap(); + assert!(payload.is_empty()); + let mut resized = false; + let result = terminal.relay(&mut engine, &leash, &mut |rows, cols| { + if (rows, cols) == (43, 91) && !resized { + report.write_all(b"R").unwrap(); + resized = true; + } + }); + if mode == "flood" { + assert!(result.is_ok()); + assert!(resized); + } else { + assert_eq!(result.unwrap_err().code, "session_cleanup_uncertain"); + } + terminal.restore().unwrap(); + report.write_all(b"D").unwrap(); + } + + #[test] + fn process_terminal_contract() { + let output = std::process::Command::new("python3") + .arg(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../tests/fixtures/native-interactive-terminal.py" + )) + .arg(std::env::current_exe().unwrap()) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "Native terminal: flood, resize, idle leash and signal restoration passed\n" + ); + } + + #[test] + fn idle_frontend_loss_closes_pending_owner_admission() { + let (input, mut frontend) = UnixStream::pair().unwrap(); + frontend.write_all(&[0; 4]).unwrap(); + let (leash, _) = Leash::receive(input.into()).unwrap(); + let (control, mut owner) = UnixStream::pair().unwrap(); + let watch = leash.watch_control(&control).unwrap(); + drop(frontend); + owner + .set_read_timeout(Some(Duration::from_secs(1))) + .unwrap(); + assert_eq!(owner.read(&mut [0]).unwrap(), 0); + drop(watch); + } + + #[test] + fn slow_resize_never_blocks_input_and_coalesces_to_latest() { + let (seen, observed) = std::sync::mpsc::channel(); + let (release, allowed) = std::sync::mpsc::channel(); + with_resize_worker( + move |rows, cols| { + seen.send((rows, cols)).unwrap(); + if rows == 1 { + allowed.recv_timeout(Duration::from_secs(2)).unwrap(); + } + }, + |notify| { + notify(1, 1); + assert_eq!( + observed.recv_timeout(Duration::from_secs(1)).unwrap(), + (1, 1) + ); + // The first request remains blocked while the I/O caller progresses. + for n in 2..=1000 { + notify(n, n); + } + release.send(()).unwrap(); + assert_eq!( + observed.recv_timeout(Duration::from_secs(2)).unwrap(), + (1000, 1000) + ); + assert!(observed.try_recv().is_err()); + }, + ); + } +} diff --git a/packages/runtime-core/src/provider/lifecycle.rs b/packages/runtime-core/src/provider/lifecycle.rs index 073953d10..5e17bd3d7 100644 --- a/packages/runtime-core/src/provider/lifecycle.rs +++ b/packages/runtime-core/src/provider/lifecycle.rs @@ -2661,6 +2661,8 @@ fn finish_absent_locked( .sync_all() .map_err(io)?; } + #[cfg(target_os = "macos")] + super::interactive_session::after_boot_stopped(candidate, owner); phase(candidate, owner, value) } diff --git a/packages/runtime-core/src/provider/managed_environment.rs b/packages/runtime-core/src/provider/managed_environment.rs index f424eb189..8a8716f52 100644 --- a/packages/runtime-core/src/provider/managed_environment.rs +++ b/packages/runtime-core/src/provider/managed_environment.rs @@ -492,6 +492,24 @@ fn receive_forwarded_scoped( pub fn receive(fd: OwnedFd, expected_plan: &str, run: &str) -> Result { receive_scoped(fd, expected_plan, run, Scope::Application) } +/// Interactive fd 3 has a length-delimited envelope followed by a lifetime +/// leash, so it cannot use the finite EOF reader. Keep the same closed codec. +#[cfg(target_os = "macos")] +pub(crate) fn receive_interactive_bytes( + bytes: &[u8], + expected_plan: &str, + run: &str, + started: Instant, +) -> Result { + if !cfg!(feature = "environment-launcher") { + return Err(refused()); + } + let managed = parse_scoped(bytes, expected_plan, run, started, Scope::Application)?; + if !managed.files().is_empty() { + return Err(refused()); + } + Ok(managed) +} /// Native private input has distinct provenance and cannot be forwarded as a Compose v1 envelope. #[cfg(feature = "native-config-plan")] diff --git a/packages/runtime-core/src/provider/mod.rs b/packages/runtime-core/src/provider/mod.rs index 33cfa995b..c93d7fbc8 100644 --- a/packages/runtime-core/src/provider/mod.rs +++ b/packages/runtime-core/src/provider/mod.rs @@ -42,6 +42,12 @@ mod image_load; pub mod image_platform; pub mod image_policy; pub mod image_reference; +#[cfg(any(target_os = "macos", test))] +mod interactive_handoff; +#[cfg(any(target_os = "macos", test))] +pub mod interactive_session; +#[cfg(any(target_os = "macos", test))] +pub mod interactive_terminal; pub mod managed_environment; #[cfg(feature = "native-config-plan")] mod native_environment; diff --git a/packages/runtime-core/src/provider/private_input.rs b/packages/runtime-core/src/provider/private_input.rs index 61325c0fb..26db1e74a 100644 --- a/packages/runtime-core/src/provider/private_input.rs +++ b/packages/runtime-core/src/provider/private_input.rs @@ -68,7 +68,22 @@ pub fn receive( Ok(bytes) } -fn validate(file: &File) -> Result<(), CandidateError> { +/// Validate the interactive report/leash channel before either reading or +/// writing. A regular file or one-way pipe cannot be a duplex frontend channel. +pub fn validate_duplex(file: &File) -> Result<(), CandidateError> { + use std::os::unix::fs::FileTypeExt; + if !file + .metadata() + .map_err(|_| refused())? + .file_type() + .is_socket() + { + return Err(refused()); + } + validate(file) +} + +pub(super) fn validate(file: &File) -> Result<(), CandidateError> { use std::os::unix::fs::FileTypeExt; let kind = file.metadata().map_err(|_| refused())?.file_type(); let fd = file.as_raw_fd(); diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index 1bee5962b..68485ebf8 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -141,6 +141,66 @@ const restartPolicyStaleUses = [ { engine: "exited", bound: 3 }, ] as const; const contracts: readonly ModelContract[] = [ + { + name: "interactive-session", + module: "Session", + states: 180, + invariant: "NoReplay", + action: "ReplayStart", + fields: [ + 'stage = "started"', + "starts = 2", + "recorded = TRUE", + "intent = TRUE", + "capable = FALSE", + ], + additionalControls: [ + { + name: "eof-retirement", + negative: true, + invariant: "RetiredWithProof", + action: "Retire", + fields: [ + 'stage = "retired"', + "ended = TRUE", + "stopped = FALSE", + "absent = FALSE", + ], + }, + { + name: "wrong-boot", + negative: true, + invariant: "RetiredWithProof", + action: "Retire", + fields: [ + 'stage = "retired"', + 'boot = "replacement"', + "replacementStopped = TRUE", + "stopped = FALSE", + "absent = FALSE", + ], + }, + ], + }, + { + name: "interactive-envelope", + module: "Envelope", + states: 15, + invariant: "NoRestoreOverPrivate", + action: "Fail", + fields: ['stage = "failed"', "pending = 2", "echo = TRUE", "ack = FALSE"], + alternativeWitnesses: [ + { + action: "Fail", + fields: [ + 'stage = "failed"', + "pending = 1", + "echo = TRUE", + "ack = FALSE", + ], + }, + ], + }, { name: "shutdown-rebase", module: "ShutdownRebase", diff --git a/src/backends/native-authored-project-exec.ts b/src/backends/native-authored-project-exec.ts index e964f9521..5638d1544 100644 --- a/src/backends/native-authored-project-exec.ts +++ b/src/backends/native-authored-project-exec.ts @@ -16,6 +16,10 @@ import { unstartedNativeAuthoredProject, withPinnedRuntimeBinary, } from "./native-authored-runtime-binary.ts"; +import { + nativeInteractiveExec, + nativeInteractiveRequested, +} from "./native-interactive-exec.ts"; import { invokeNativeRuntime, type NativeRuntimeSelection, @@ -137,6 +141,49 @@ export async function nativeAuthoredProjectExec(input: { ) { return refused(); } + if (nativeInteractiveRequested()) { + // Finish the held saved-status observation before attaching. A normal down + // may advance these records while the terminal session is still open. + const selected = await withStatus(scope, async (saved) => { + await saved.assertFresh(); + const unstarted = unstartedNativeAuthoredProject(saved); + if (unstarted) { + return unstarted; + } + return requireReadyServiceReceipt(saved, scope, service) ?? refused(); + }); + if (selected instanceof NativeAuthoredProjectNotStartedError) { + throw selected; + } + const container = selected.resources[`container:${service}`]?.id; + if (!container) { + return refused(); + } + const exitCode = await nativeInteractiveExec({ + runtime, + projectRoot: scope.projectRoot, + branch: scope.branch, + run: selected.review.provenance.run, + owner: selected.owner, + plan: selected.review.review_id, + namespace: selected.review.provenance.namespace, + service, + command, + workdir, + native: true, + signal, + }); + return { + backend: "native", + run: selected.review.provenance.run, + service, + container, + exitCode, + stdout: new Uint8Array(), + stderr: new Uint8Array(), + truncated: false, + }; + } const result = await withStatus(scope, async (saved) => { active(); // A never-started selection is a closed refusal, not an unknown outcome. diff --git a/src/backends/native-interactive-exec.ts b/src/backends/native-interactive-exec.ts new file mode 100644 index 000000000..f0e192b43 --- /dev/null +++ b/src/backends/native-interactive-exec.ts @@ -0,0 +1,378 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import { Duplex } from "node:stream"; +import { ClosedCliError } from "../lib/cli-result.ts"; +import { isRecord } from "../lib/guards.ts"; +import { keys } from "../lib/native-compose-private-state.ts"; +import { openTerminalControl } from "../lib/tty-process-group.ts"; +import { withPinnedRuntimeBinary } from "./native-authored-runtime-binary.ts"; +import { + invokeNativeRuntime, + type NativeRuntimeSelection, +} from "./native-runtime-client.ts"; + +const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; +const CODE = /^[a-z][a-z0-9_]{0,63}$/; +function refused(): never { + throw new ClosedCliError( + "Native interactive exec could not confirm its terminal or session; no request was replayed." + ); +} + +/** Match Compose's terminal-driven exec selection; pipes retain finite exec. */ +export function nativeInteractiveRequested(): boolean { + return Boolean( + process.stdin.isTTY && + process.stdout.isTTY && + process.env.HACK_NO_INTERACTIVE !== "1" + ); +} + +type Report = { + code: number; + session?: string; + retained: boolean; + failure?: string; +}; +export function parseInteractiveReport(value: unknown): Report { + if (!isRecord(value) || value.version !== 1) { + return refused(); + } + if ( + value.outcome === "completed" && + keys(value, "completion,outcome,version") + ) { + const r = value.completion; + if ( + !(isRecord(r) && keys(r, "exit_code,retained,session")) || + typeof r.exit_code !== "number" || + !Number.isInteger(r.exit_code) || + r.exit_code < 0 || + r.exit_code > 255 || + typeof r.retained !== "boolean" || + typeof r.session !== "string" || + !UUID.test(r.session) + ) { + return refused(); + } + return { code: r.exit_code, session: r.session, retained: r.retained }; + } + if ( + value.outcome === "refused" && + keys(value, "code,outcome,session,version") && + typeof value.code === "string" && + CODE.test(value.code) && + (value.session === null || + (typeof value.session === "string" && UUID.test(value.session))) + ) { + return { + code: 1, + retained: value.session !== null, + ...(typeof value.session === "string" ? { session: value.session } : {}), + failure: value.code, + }; + } + return refused(); +} +function quote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +/** The child stays in the foreground group. fd 3 is duplex: private envelope, + * then lifetime leash on input; a bounded value-free result on output. The + * frontend owns a second terminal snapshot for abrupt native-child death. */ +type InteractiveOptions = { + readonly runtime: NativeRuntimeSelection; + readonly projectRoot: string; + readonly branch: string | null; + readonly run: string; + readonly owner: string; + readonly plan: string; + readonly namespace: string; + readonly service: string; + readonly command: readonly string[]; + readonly workdir?: string; + readonly native: boolean; + readonly payload?: Uint8Array; + readonly expectedContainer?: string; + readonly expectedGeneration?: string; + readonly signal?: AbortSignal; +}; + +export async function nativeInteractiveExec( + opts: InteractiveOptions +): Promise { + if ( + !nativeInteractiveRequested() || + (opts.payload?.length ?? 0) > 256 * 1024 || + opts.signal?.aborted + ) { + return refused(); + } + const remedy = `hack down --path ${quote(opts.projectRoot)}${opts.branch ? ` --branch ${quote(opts.branch)}` : ""}`; + // Cancellation closes the child leash; it must not suppress the subsequent + // read-only receipt query that reports an uncertain admission. + return await withPinnedRuntimeBinary( + { binary: opts.runtime.binary, refused }, + async (pin) => { + const terminal = openTerminalControl(); + const attributes = terminal.attributes(); + if (!attributes || terminal.foreground() !== terminal.group()) { + terminal.close(); + return refused(); + } + let report: Report | undefined; + let exit = 1; + let restorationFailed = false; + try { + pin.checkSync(); + if (opts.signal?.aborted) { + return refused(); + } + ({ report, exit } = await runInteractiveChild( + spawnInteractiveChild(opts), + opts + )); + } finally { + const restored = terminal.restoreAttributes(attributes); + terminal.close(); + if (!restored) { + restorationFailed = true; + process.stderr.write( + "interactive_terminal_restore_failed: run stty sane in this terminal.\n" + ); + } + } + return await finishInteractiveReport({ + opts, + report, + exit, + restorationFailed, + remedy, + check: () => pin.checkSync(), + }); + } + ); +} + +function spawnInteractiveChild(opts: InteractiveOptions): ChildProcess { + const env: Record = {}; + for (const key of ["PATH", "HOME", "TMPDIR", "USER", "LOGNAME"]) { + const value = process.env[key]; + if (value !== undefined) { + env[key] = value; + } + } + return spawn( + opts.runtime.binary, + [ + "--candidate-root", + opts.runtime.home, + "graph", + "exec-interactive", + "--run-id", + opts.run, + "--service", + opts.service, + "--expect-owner", + opts.owner, + "--expect-plan", + opts.plan, + "--expect-namespace", + opts.namespace, + ...(opts.native ? ["--native"] : []), + ...(opts.workdir ? ["--workdir", opts.workdir] : []), + ...(opts.expectedContainer + ? ["--expect-container", opts.expectedContainer] + : []), + ...(opts.expectedGeneration + ? ["--expect-generation", opts.expectedGeneration] + : []), + "--", + ...opts.command, + ], + { + cwd: opts.projectRoot, + env, + stdio: ["inherit", "inherit", "inherit", "pipe"], + detached: false, + } + ); +} + +async function runInteractiveChild( + child: ChildProcess, + opts: InteractiveOptions +): Promise<{ report?: Report; exit: number }> { + let report: Report | undefined; + let exit = 1; + const control = child.stdio[3]; + if (!(control instanceof Duplex)) { + child.kill("SIGKILL"); + await new Promise((resolve) => child.once("close", () => resolve())); + return refused(); + } + const chunks: Buffer[] = []; + let size = 0; + let invalid = false; + let killTimer: ReturnType | undefined; + let deliveryTimer: ReturnType | undefined; + const cancel = () => { + control.destroy(); + child.kill("SIGTERM"); + killTimer ??= setTimeout(() => child.kill("SIGKILL"), 2000); + }; + const ended = new Promise((resolve) => { + child.once("error", () => { + invalid = true; + }); + child.once("close", (code, signal) => + resolve(code ?? (signal === "SIGINT" ? 130 : 1)) + ); + }); + control.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > 4096) { + invalid = true; + cancel(); + } else { + chunks.push(chunk); + } + }); + control.on("error", () => { + invalid = true; + cancel(); + }); + opts.signal?.addEventListener("abort", cancel, { once: true }); + // Adopted exec has no enclosing authored signal scope. + process.on("SIGINT", cancel); + process.on("SIGTERM", cancel); + const payload = Buffer.alloc(4 + (opts.payload?.length ?? 0)); + payload.writeUInt32BE(opts.payload?.length ?? 0); + if (opts.payload) { + payload.set(opts.payload, 4); + } + try { + deliveryTimer = setTimeout(cancel, 5000); + control.write(payload, () => { + payload.fill(0); + clearTimeout(deliveryTimer); + }); + if (opts.signal?.aborted) { + cancel(); + } + exit = await ended; + if (!invalid) { + try { + report = parseInteractiveReport( + JSON.parse(Buffer.concat(chunks).toString("utf8")) + ); + } catch { + /* A lost child leaves durable evidence for the query below. */ + } + } + } finally { + payload.fill(0); + clearTimeout(deliveryTimer); + clearTimeout(killTimer); + opts.signal?.removeEventListener("abort", cancel); + process.off("SIGINT", cancel); + process.off("SIGTERM", cancel); + control.destroy(); + } + + return { report, exit }; +} + +async function reportRetainedSessions( + opts: InteractiveOptions, + check: () => void, + remedy: string +): Promise { + const retained = await (async () => { + check(); + return await invokeNativeRuntime({ + runtime: opts.runtime, + cwd: opts.projectRoot, + args: [ + "graph", + "interactive-sessions", + "--run-id", + opts.run, + "--service", + opts.service, + "--json", + ], + timeoutMs: 5000, + }); + })().catch(() => null); + if ( + isRecord(retained) && + keys(retained, "sessions,version") && + retained.version === 1 && + Array.isArray(retained.sessions) && + retained.sessions.length <= 1024 && + retained.sessions.every( + (id: unknown) => typeof id === "string" && UUID.test(id) + ) + ) { + if (retained.sessions.length > 0) { + process.stderr.write( + "Retained sessions for this service (the interrupted session may be among them):\n" + ); + } + for (const id of retained.sessions) { + process.stderr.write( + `session_cleanup_uncertain: session ${id}; cleanup: ${remedy}\n` + ); + } + } else { + process.stderr.write( + `session_cleanup_uncertain: session evidence unavailable; cleanup: ${remedy}\n` + ); + } +} + +async function finishInteractiveReport({ + opts, + report, + exit, + restorationFailed, + remedy, + check, +}: { + opts: InteractiveOptions; + report?: Report; + exit: number; + restorationFailed: boolean; + remedy: string; + check: () => void; +}): Promise { + if (report?.failure === "session_receipt_limit") { + process.stderr.write( + "session_receipt_limit: the native home has 1024 retained interactive sessions; run hack down for a project with retained sessions before opening another.\n" + ); + return 1; + } + if (!report || (report.failure && !report.session)) { + // SIGKILL cannot send a final report. Query only value-free receipts after + // the child is reaped; never replay its start to recover an outcome. + await reportRetainedSessions(opts, check, remedy); + if (report?.failure) { + process.stderr.write( + `Native interactive exec ended (${report.failure}).\n` + ); + } + return exit === 0 ? 1 : exit; + } + if (report.retained) { + process.stderr.write( + `session_cleanup_uncertain: session ${report.session}; cleanup: ${remedy}\n` + ); + } + if (report.failure) { + process.stderr.write( + `Native interactive exec ended (${report.failure}).\n` + ); + } + return restorationFailed && report.code === 0 ? 1 : report.code; +} diff --git a/src/backends/native-project-exec.ts b/src/backends/native-project-exec.ts index 38bbb5e85..019127a9b 100644 --- a/src/backends/native-project-exec.ts +++ b/src/backends/native-project-exec.ts @@ -1,7 +1,12 @@ import { noteNativeExitConfirmed } from "../lib/background-command/native-exit.ts"; import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; +import { nativeAuthoredExecOptions } from "./native-authored-exec-options.ts"; import { prepareNativeExecEnvironment } from "./native-exec-environment.ts"; +import { + nativeInteractiveExec, + nativeInteractiveRequested, +} from "./native-interactive-exec.ts"; import { refreshNativeProjectDependencies } from "./native-project-dependency-refresh.ts"; import { nativeProjectPs } from "./native-project-observe.ts"; import type { NativeProjectRunScope } from "./native-project-run.ts"; @@ -13,7 +18,6 @@ import { type NativeRuntimeSelection, } from "./native-runtime-client.ts"; -const SERVICE = /^[A-Za-z0-9_.-]{1,128}$/; function refused(): Error { return new ClosedCliError( "Native exec identity or response is unconfirmed; command effects may have occurred. No request was replayed." @@ -84,7 +88,7 @@ async function freshDelivery(opts: { throw refused(); } if (Object.keys(values).length === 0) { - return { payload: undefined, extra }; + return { payload: undefined, extra, generation: selected.generation }; } const payload = Buffer.from( JSON.stringify({ @@ -104,7 +108,7 @@ async function freshDelivery(opts: { selected.generation, "--environment-stdin" ); - return { payload, extra }; + return { payload, extra, generation: selected.generation }; } /** One bounded noninteractive command; fresh values use private stdin when a Compose source is supplied. */ export async function nativeProjectExec(opts: { @@ -116,20 +120,7 @@ export async function nativeProjectExec(opts: { readonly composeFile?: string; readonly invoke?: typeof invokeNativeRuntime; }) { - if ( - !SERVICE.test(opts.service) || - opts.argv.length < 1 || - opts.argv.length > 256 || - !opts.argv[0] || - opts.argv.some( - (arg) => arg.includes("\0") || Buffer.byteLength(arg) > 16_384 - ) || - opts.argv.reduce((n, arg) => n + Buffer.byteLength(arg), 0) > 65_536 || - (opts.workdir !== undefined && - (!opts.workdir.startsWith("/") || - opts.workdir.includes("\0") || - Buffer.byteLength(opts.workdir) > 4096)) - ) { + if (!nativeAuthoredExecOptions(opts.service, opts.argv, opts.workdir)) { throw new ClosedCliError( "Native exec requires one service and bounded command arguments with an optional absolute workdir." ); @@ -172,14 +163,42 @@ export async function nativeProjectExec(opts: { throw refused(); } const invoke = opts.invoke ?? invokeNativeRuntime; - const { payload, extra } = opts.composeFile + const { payload, extra, generation } = opts.composeFile ? await freshDelivery({ ...opts, run: before.run, container: service.container, composeFile: opts.composeFile, }) - : { payload: undefined, extra: [] }; + : { payload: undefined, extra: [], generation: undefined }; + if (nativeInteractiveRequested()) { + try { + const exitCode = await nativeInteractiveExec({ + runtime: opts.runtime, + projectRoot: opts.scope.projectRoot, + branch: opts.scope.branch, + run: before.run, + owner: refreshed.mapping.owner, + plan: refreshed.mapping.planId, + namespace: refreshed.mapping.namespace, + service: opts.service, + command: opts.argv, + workdir: opts.workdir, + native: false, + payload, + expectedContainer: generation ? service.container : undefined, + expectedGeneration: generation, + }); + return { + exitCode, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + truncated: false, + }; + } finally { + payload?.fill(0); + } + } let value: unknown; try { value = await invoke({ diff --git a/src/lib/native-authored-command.ts b/src/lib/native-authored-command.ts index 935245189..c6a4c60c0 100644 --- a/src/lib/native-authored-command.ts +++ b/src/lib/native-authored-command.ts @@ -61,7 +61,7 @@ function unsupported(): never { throw new HackCliError({ code: "E_NATIVE_PROJECT_UNSUPPORTED", message: - "Native authored execution requires whole-project foreground up or restart, owner-mediated down, ps, single-service logs, noninteractive single-service exec or run, or explicit stored-generation down --recover on macOS. This request ran no input or runtime operation.", + "Native authored execution requires whole-project foreground up or restart, owner-mediated down, ps, single-service logs, single-service exec or noninteractive run, or explicit stored-generation down --recover on macOS. This request ran no input or runtime operation.", }); } @@ -931,8 +931,8 @@ export async function tryNativeAuthoredCommand( } /** - * Exec and one-off run share the closed noninteractive command domain: one - * service, a literal argv, an optional absolute workdir, no TTY/stdin, no + * Exec and one-off run share the closed argument domain: one + * service, a literal argv, an optional absolute workdir, no * profile/env/service-list/JSON/detach/log options. */ function assertCommandOptions( diff --git a/src/lib/tty-process-group.ts b/src/lib/tty-process-group.ts index 4a9254250..615663bba 100644 --- a/src/lib/tty-process-group.ts +++ b/src/lib/tty-process-group.ts @@ -47,10 +47,13 @@ export function openTerminalControl() { }, restoreAttributes: (value: Uint8Array | null) => { if (value) { - withoutBackgroundStop(() => - libc.symbols.tcsetattr(descriptor, 0, value) + return ( + withoutBackgroundStop(() => + libc.symbols.tcsetattr(descriptor, 0, value) + ) === 0 ); } + return false; }, close: () => { if (controllingDescriptor !== null) { diff --git a/tests/fixtures/environment-launcher-tty.py b/tests/fixtures/environment-launcher-tty.py new file mode 100644 index 000000000..87c771408 --- /dev/null +++ b/tests/fixtures/environment-launcher-tty.py @@ -0,0 +1,98 @@ +"""Real Linux PTY boundary for the guest launcher; all values are synthetic.""" +import base64 +import json +import os +import pty +import select +import subprocess +import sys +import termios +import time + + +def read_line(fd, limit=128, timeout=7): + result = bytearray() + until = time.monotonic() + timeout + while not result.endswith(b"\n"): + assert len(result) < limit, "handshake overflow" + wait = until - time.monotonic() + assert wait > 0 and select.select([fd], [], [], wait)[0], "handshake timeout" + result.extend(os.read(fd, 1)) + return bytes(result) + + +def no_output(fd): + assert not select.select([fd], [], [], .1)[0], "unexpected PTY output" + + +def case(binary, mode): + master, slave = pty.openpty() + original = termios.tcgetattr(slave) + original[3] |= termios.ECHO | termios.ECHONL | termios.ICANON | termios.ISIG + original[1] |= termios.OPOST | termios.ONLCR + termios.tcsetattr(slave, termios.TCSANOW, original) + command = ( + "import os,sys; " + "assert os.environ['TOKEN'] == 'synthetic-private-canary\\x10\\x11\\n'; " + "print('command-ready',flush=True); " + "assert sys.stdin.readline() == 'user-input\\n'; sys.exit(7)" + ) + process = subprocess.Popen( + [binary, "--exec-environment-tty-v2", sys.executable, "-c", command], + stdin=slave, stdout=slave, stderr=slave, close_fds=True, + env={"PATH": "/usr/bin:/bin"}, + ) + try: + marker = read_line(master) + assert marker.startswith(b"HKEE2 ") and marker.endswith(b"\n") + started = int(marker[6:-1]) + raw = termios.tcgetattr(slave) + assert raw[3] & (termios.ECHO | termios.ECHONL | termios.ICANON | termios.ISIG | termios.IEXTEN) == 0 + assert raw[0] & (termios.IXON | termios.ICRNL | termios.INLCR | termios.IGNCR | termios.ISTRIP | termios.BRKINT) == 0 + assert raw[1] & termios.OPOST == 0 + assert raw[6][termios.VMIN] == 1 and raw[6][termios.VTIME] == 0 + payload = json.dumps({ + "version": 1, "expires": started + (0 if mode == "expired" else 30), + "values": {"TOKEN": "synthetic-private-canary\x10\x11\n"}, + }).encode() + encoded = base64.b64encode(payload) + assert all(c not in encoded for c in (0x10, 0x11, 0x0a, 0x0d)) + if mode == "invalid": + encoded = b"!!!!" + length = len(encoded) + (1 if mode == "short" else 0) + wire = b"HKEE2 " + str(length).encode() + b"\n" + encoded + if mode == "extra": + wire += b"unexpected-input" + if mode != "timeout": + # One write ensures the unexpected suffix is pending before parsing. + assert os.write(master, wire) == len(wire) + if mode == "success": + assert read_line(master) == b"HKEE2 OK\r\n", "private input echoed or ACK missing" + assert termios.tcgetattr(slave) == original, "terminal not restored before ACK" + assert read_line(master) == b"command-ready\r\n" + os.write(master, b"user-input\n") + assert read_line(master) == b"user-input\r\n" + assert process.wait(timeout=3) == 7 + else: + assert process.wait(timeout=7) == 125 + assert termios.tcgetattr(slave) == raw, "error restored echo" + no_output(master) + os.write(master, b"late-private-canary\n") + no_output(master) + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=3) + os.close(master) + os.close(slave) + + +for scenario in ("success", "invalid", "expired", "extra", "short", "timeout"): + case(sys.argv[1], scenario) +non_tty = subprocess.run( + [sys.argv[1], "--exec-environment-tty-v2", "/bin/true"], + input=b"", stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=7, + env={"PATH": "/usr/bin:/bin"}, +) +assert non_tty.returncode == 125 and non_tty.stdout == b"" and non_tty.stderr == b"" +print("PTY handshake: seven cases passed") diff --git a/tests/fixtures/native-interactive-frontend.py b/tests/fixtures/native-interactive-frontend.py new file mode 100644 index 000000000..2e0f4a1a2 --- /dev/null +++ b/tests/fixtures/native-interactive-frontend.py @@ -0,0 +1,73 @@ +"""Private controlling PTY for the real Bun frontend, including compiled Bun.""" +import fcntl +import os +import pty +import select +import signal +import subprocess +import sys +import termios +import time + +root = sys.argv[1] +command = sys.argv[2:] +for mode in ("normal", "child-kill", "frontend-kill", "cancel"): + for name in ("reader.pid", "reader.closed"): + try: + os.unlink(os.path.join(root, name)) + except FileNotFoundError: + pass + master, slave = pty.openpty() + original = termios.tcgetattr(slave) + def session(): + os.setsid() + fcntl.ioctl(slave, termios.TIOCSCTTY, 0) + process = subprocess.Popen(command + [root, mode], cwd=root, stdin=slave, + stdout=slave, stderr=slave, preexec_fn=session) + reader = None + output = bytearray() + try: + deadline = time.monotonic() + 15 + while b"child-ready\n" not in output: + assert time.monotonic() < deadline, repr(output) + if select.select([master], [], [], .1)[0]: + output.extend(os.read(master, 8192)) + assert process.poll() is None or b"child-ready\n" in output, repr(output) + with open(os.path.join(root, "reader.pid")) as file: + reader = int(file.read()) + if mode == "child-kill": + os.kill(reader, signal.SIGKILL) + elif mode == "frontend-kill": + process.kill() + elif mode == "cancel": + process.terminate() + code = process.wait(timeout=10) + if mode == "normal": + assert code == 7, code + else: + assert code != 0, code + if mode == "frontend-kill": + until = time.monotonic() + 5 + while not os.path.exists(os.path.join(root, "reader.closed")): + assert time.monotonic() < until, "frontend death did not close fd3" + time.sleep(.01) + assert termios.tcgetattr(slave) == original, "terminal left raw" + while select.select([master], [], [], 0)[0]: + output.extend(os.read(master, 8192)) + assert b"synthetic-private-envelope-canary" not in output + if mode != "frontend-kill": + assert b"session_cleanup_uncertain: session 00000000-0000-4000-8000-000000000001" in output, repr(output) + assert b"hack down --path" in output and b"--branch 'topic'" in output + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + if reader is not None: + try: + if os.getsid(reader) == process.pid: + os.kill(reader, signal.SIGKILL) + except ProcessLookupError: + pass + os.close(master) + os.close(slave) +print("Frontend PTY: four cases passed") diff --git a/tests/fixtures/native-interactive-frontend.ts b/tests/fixtures/native-interactive-frontend.ts new file mode 100644 index 000000000..3dae26962 --- /dev/null +++ b/tests/fixtures/native-interactive-frontend.ts @@ -0,0 +1,19 @@ +import { nativeInteractiveExec } from "../../src/backends/native-interactive-exec.ts"; + +const [root, mode] = process.argv.slice(2); +if (!(root && mode)) { + throw new Error("fixture arguments"); +} +process.exitCode = await nativeInteractiveExec({ + runtime: { binary: `${root}/runtime`, home: root }, + projectRoot: root, + branch: "topic", + run: "a".repeat(32), + owner: "b".repeat(32), + plan: "c".repeat(64), + namespace: "d".repeat(64), + service: "web", + command: [mode], + native: false, + payload: new TextEncoder().encode("synthetic-private-envelope-canary"), +}); diff --git a/tests/fixtures/native-interactive-runtime.py b/tests/fixtures/native-interactive-runtime.py new file mode 100644 index 000000000..2469437bc --- /dev/null +++ b/tests/fixtures/native-interactive-runtime.py @@ -0,0 +1,44 @@ +"""Fake Engine-side process: prove the frontend's fd3 and fallback restoration.""" +import json +import os +import select +import signal +import socket +import sys +import termios +import tty + +session = "00000000-0000-4000-8000-000000000001" +if "interactive-sessions" in sys.argv: + print(json.dumps({"version": 1, "sessions": [session]})) + sys.exit(0) +control = socket.socket(fileno=3) +length = control.recv(4) +assert len(length) == 4 +remaining = int.from_bytes(length, "big") +payload = bytearray() +while len(payload) < remaining: + data = control.recv(remaining - len(payload)) + assert data + payload.extend(data) +assert payload == b"synthetic-private-envelope-canary" +assert os.getpgrp() == os.tcgetpgrp(0) +original = termios.tcgetattr(0) +tty.setraw(0) +# Like the native guard, survive session-leader loss long enough to observe +# the leash and restore. Rust's separate process fixture tests its real handler. +signal.signal(signal.SIGHUP, lambda *_: None) +with open("reader.pid", "w") as file: + file.write(str(os.getpid())) +os.write(1, b"child-ready\n") +mode = sys.argv[-1] +if mode == "normal": + control.sendall(json.dumps({"version": 1, "outcome": "completed", "completion": { + "session": session, "exit_code": 7, "retained": True}}).encode() + b"\n") + # Deliberately leave raw mode: the frontend's independent snapshot owns it. + sys.exit(7) +assert select.select([control], [], [], 10)[0], "leash deadline" +assert control.recv(1) == b"", "unexpected post-envelope data" +termios.tcsetattr(0, termios.TCSANOW, original) +with open("reader.closed", "w") as file: + file.write("closed") diff --git a/tests/fixtures/native-interactive-terminal.py b/tests/fixtures/native-interactive-terminal.py new file mode 100644 index 000000000..c002a02ff --- /dev/null +++ b/tests/fixtures/native-interactive-terminal.py @@ -0,0 +1,94 @@ +"""Exercise the actual native terminal guard in a private process and PTY.""" +import os +import pty +import select +import signal +import socket +import subprocess +import sys +import termios +import time +import fcntl +import struct + + +def run(mode): + master, slave = pty.openpty() + original = termios.tcgetattr(slave) + control, child_control = socket.socketpair() + engine, child_engine = socket.socketpair() + env = {"PATH": "/usr/bin:/bin", "HACK_INTERACTIVE_CHILD": mode, + "HACK_INTERACTIVE_CONTROL": str(child_control.fileno()), + "HACK_INTERACTIVE_ENGINE": str(child_engine.fileno())} + + def session(): + os.setsid() + fcntl.ioctl(slave, termios.TIOCSCTTY, 0) + + process = subprocess.Popen( + [sys.argv[1], "--exact", "provider::interactive_terminal::tests::child_session", + "--nocapture", "--test-threads=1"], + stdin=slave, stdout=slave, stderr=slave, env=env, + pass_fds=(child_control.fileno(), child_engine.fileno(), slave), preexec_fn=session, + ) + child_control.close() + child_engine.close() + control.sendall(bytes(4)) + try: + deadline = time.monotonic() + 10 + while termios.tcgetattr(slave)[3] & termios.ICANON: + assert process.poll() is None, "child exited before raw mode" + assert time.monotonic() < deadline, "raw mode deadline" + time.sleep(.005) + os.set_blocking(master, False) + engine.setblocking(False) + control.settimeout(10) + # Discard the Rust test runner's prelude, before any fixture output. + while select.select([master], [], [], 0)[0]: + os.read(master, 8192) + if mode == "flood": + incoming = b"i" * 200000 + outgoing = b"o" * 200000 + sent_input = sent_output = 0 + got_input = bytearray() + got_output = bytearray() + while len(got_input) < len(incoming) or len(got_output) < len(outgoing): + assert time.monotonic() < deadline, "bidirectional relay stalled" + reads, writes, _ = select.select( + [master, engine], + ([master] if sent_input < len(incoming) else []) + + ([engine] if sent_output < len(outgoing) else []), [], .1) + if master in writes: + sent_input += os.write(master, incoming[sent_input:sent_input + 4096]) + if engine in writes: + sent_output += engine.send(outgoing[sent_output:sent_output + 4096]) + if master in reads: + got_output.extend(os.read(master, 8192)) + if engine in reads: + got_input.extend(engine.recv(8192)) + assert got_input == incoming and got_output == outgoing + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 43, 91, 0, 0)) + assert control.recv(1) == b"R", "resize was not observed" + engine.shutdown(socket.SHUT_WR) + elif mode == "leash": + control.shutdown(socket.SHUT_WR) + elif mode == "signal": + process.send_signal(signal.SIGTERM) + else: + raise AssertionError("unknown scenario") + assert control.recv(1) == b"D", "child did not finish and restore" + assert termios.tcgetattr(slave) == original, "terminal attributes not restored" + assert process.wait(timeout=5) == 0 + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + control.close() + engine.close() + os.close(master) + os.close(slave) + + +for mode in ("flood", "leash", "signal"): + run(mode) +print("Native terminal: flood, resize, idle leash and signal restoration passed") diff --git a/tests/models/tla/interactive-envelope/COVERAGE.md b/tests/models/tla/interactive-envelope/COVERAGE.md new file mode 100644 index 000000000..1643e2d95 --- /dev/null +++ b/tests/models/tla/interactive-envelope/COVERAGE.md @@ -0,0 +1,21 @@ +# Interactive private envelope + +`Envelope.tla` exhausts 15 states for one handshake and two abstract private bytes. +Raw mode precedes the ready marker. The host sends only after that marker; +validation requires consuming every private byte. Restoration precedes ACK, and +user input is admitted only after ACK. Error exits leave echo disabled, including +for bytes still in flight. The `negative` control restores on error and must fail +`NoRestoreOverPrivate` at `Fail`, with pending bytes and echo enabled. + +`interactiveFreshExec`/`interactiveEnvelope` in `environment-launcher.zig` own +guest termios and parsing. `engine::interactive_exec::deliver` owns the host +marker, base64 delivery and ACK gate. `Terminal::relay` starts only after that +gate. The Python PTY fixture tests successful restoration, malformed base64, +expiry, extra/short input, timeout and non-TTY refusal. Error tests send late +synthetic bytes and require no echo. The Rust handshake test preserves output +coalesced with ACK and proves malformed/old markers receive no private bytes. + +Bounds and assumptions: 64 KiB decoded envelope, 5-second delivery, exact +termios semantics and a trusted pinned launcher. Real Docker detach scanning, +PTY wiring, job control, same-user container access and VM behavior are outside +the model. It proves no liveness or isolation from the command's own user. diff --git a/tests/models/tla/interactive-envelope/Envelope.tla b/tests/models/tla/interactive-envelope/Envelope.tla new file mode 100644 index 000000000..4e64a0edd --- /dev/null +++ b/tests/models/tla/interactive-envelope/Envelope.tla @@ -0,0 +1,41 @@ +------------------------------ MODULE Envelope ----------------------------- +EXTENDS Naturals, TLC +CONSTANT RestoreOnError +VARIABLES stage, echo, ready, pending, validated, ack, leaked +vars == <> +Init == /\ stage = "initial" /\ echo = TRUE /\ ready = FALSE + /\ pending = 0 /\ validated = FALSE /\ ack = FALSE /\ leaked = FALSE +TypeOK == /\ stage \in {"initial", "raw", "ready", "reading", "validated", "restored", "acked", "executed", "failed"} + /\ echo \in BOOLEAN /\ ready \in BOOLEAN /\ pending \in 0..2 + /\ validated \in BOOLEAN /\ ack \in BOOLEAN /\ leaked \in BOOLEAN +Raw == /\ stage = "initial" /\ echo' = FALSE /\ stage' = "raw" + /\ UNCHANGED <> +Ready == /\ stage = "raw" /\ ready' = TRUE /\ stage' = "ready" + /\ UNCHANGED <> +SendPrivate == /\ stage = "ready" /\ ready /\ pending' = 2 /\ stage' = "reading" + /\ leaked' = leaked \/ echo + /\ UNCHANGED <> +ReadByte == /\ stage = "reading" /\ pending > 0 /\ pending' = pending - 1 + /\ leaked' = leaked \/ echo + /\ UNCHANGED <> +Validate == /\ stage = "reading" /\ pending = 0 /\ stage' = "validated" /\ validated' = TRUE + /\ UNCHANGED <> +Restore == /\ stage = "validated" /\ stage' = "restored" /\ echo' = TRUE + /\ UNCHANGED <> +Ack == /\ stage = "restored" /\ ack' = TRUE /\ stage' = "acked" + /\ UNCHANGED <> +Execute == /\ stage = "acked" /\ stage' = "executed" + /\ UNCHANGED <> +\* Pending includes bytes still in flight: flushing the slave cannot erase them. +Fail == /\ stage \in {"raw", "ready", "reading", "validated"} + /\ stage' = "failed" /\ echo' = RestoreOnError + /\ UNCHANGED <> +LateByte == /\ stage = "failed" /\ pending > 0 /\ pending' = pending - 1 + /\ leaked' = leaked \/ echo + /\ UNCHANGED <> +Next == Raw \/ Ready \/ SendPrivate \/ ReadByte \/ Validate \/ Restore \/ Ack \/ Execute \/ Fail \/ LateByte +Spec == Init /\ [][Next]_vars +NoPrivateEcho == ~leaked +NoRestoreOverPrivate == pending > 0 => ~echo +AckAfterValidation == ack => validated /\ pending = 0 /\ echo +============================================================================= diff --git a/tests/models/tla/interactive-envelope/negative.cfg b/tests/models/tla/interactive-envelope/negative.cfg new file mode 100644 index 000000000..75b355b5f --- /dev/null +++ b/tests/models/tla/interactive-envelope/negative.cfg @@ -0,0 +1,4 @@ +CONSTANT RestoreOnError = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK NoPrivateEcho NoRestoreOverPrivate AckAfterValidation +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/interactive-envelope/positive.cfg b/tests/models/tla/interactive-envelope/positive.cfg new file mode 100644 index 000000000..deed4df19 --- /dev/null +++ b/tests/models/tla/interactive-envelope/positive.cfg @@ -0,0 +1,4 @@ +CONSTANT RestoreOnError = FALSE +SPECIFICATION Spec +INVARIANTS TypeOK NoPrivateEcho NoRestoreOverPrivate AckAfterValidation +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/interactive-session/COVERAGE.md b/tests/models/tla/interactive-session/COVERAGE.md new file mode 100644 index 000000000..cb82984d3 --- /dev/null +++ b/tests/models/tla/interactive-session/COVERAGE.md @@ -0,0 +1,28 @@ +# Interactive session receipt + +`Session.tla` exhausts 180 states for one session, original/replacement boot and +at most two start attempts. `NoReplay`, `WriteAhead` and `RetiredWithProof` hold. +The `negative` control enables `ReplayStart` after a crash and must violate +`NoReplay` with `starts = 2`. `eof-retirement` enables retirement on EOF and must +violate `RetiredWithProof` with neither stopped-boot nor container-absence proof. +`wrong-boot` deliberately accepts a replacement boot's stop proof and must fail +the same invariant while the original boot's proof remains false. `StopBoot` is +enabled only for the original boot; `StopReplacement` tracks the other proof +separately. Owner replacement is covered by the Rust exact-binding test, not by +the model, which has no owner variable. + +| Model transition | Implementation and regression | +| --- | --- | +| Admit, Record | `interactive_session::admit`, `Intent::record_exec`: pending write, file fsync, rename, directory fsync. Torn pending files do not authorize effects; malformed final records refuse. | +| Withdraw | Only the live `Intent` before create dispatch can withdraw; `known_undispatched_admission_can_withdraw_its_own_intent` preserves another admission. | +| Create, Start | `Engine::interactive_start`: repeated freshness/guest/effect guards; exec ID recorded before exactly one start. Any error after create dispatch retains evidence. | +| Attach, EOF, Crash | Owner consumes its fd after SCM_RIGHTS send; terminal EOF or disconnect never withdraws the receipt. Handoff tests preserve early output and close rejected descriptors; process tests cover lost frontend and terminal restoration. | +| StopBoot, RemoveContainer, ReplaceBoot | `finish_absent_locked` publishes exact owner/boot proof before changing phase. `reconcile_observed` requires same boot and owner for container 404. New boot alone is unknown. | +| Retire | Snapshot without holding the store lock during observation; reacquire and compare receipt before removal. Tests cover record advancement during proof and durable stopped-boot proof surviving owner replacement. | + +Atomic durable publication abstracts the pending/fsync/rename sequence; filesystem +tests exercise retained and final names but do not simulate power failure. Exact +identity, real Engine observations and the VM absence proof are assumptions. +The model excludes multiple sessions, physical descriptor transfer, terminal job +control and scheduler fairness. There is no eventual-cleanup claim. A normal exec +exit can leave descendants and therefore retains its receipt until exact cleanup. diff --git a/tests/models/tla/interactive-session/Session.tla b/tests/models/tla/interactive-session/Session.tla new file mode 100644 index 000000000..051f9d5b6 --- /dev/null +++ b/tests/models/tla/interactive-session/Session.tla @@ -0,0 +1,57 @@ +------------------------------ MODULE Session ------------------------------ +EXTENDS Naturals, TLC +CONSTANTS Replay, RetireOnEOF, WrongBoot +VARIABLES stage, intent, recorded, starts, capable, boot, stopped, absent, ended, replacementStopped +vars == <> +Init == /\ stage = "new" /\ intent = FALSE /\ recorded = FALSE + /\ starts = 0 /\ capable = TRUE /\ boot = "original" + /\ stopped = FALSE /\ absent = FALSE /\ ended = FALSE /\ replacementStopped = FALSE +TypeOK == /\ stage \in {"new", "admitted", "created", "recorded", "started", "attached", "ended", "abandoned", "retired", "withdrawn"} + /\ intent \in BOOLEAN /\ recorded \in BOOLEAN /\ capable \in BOOLEAN + /\ starts \in 0..2 /\ boot \in {"original", "replacement"} + /\ stopped \in BOOLEAN /\ absent \in BOOLEAN /\ ended \in BOOLEAN /\ replacementStopped \in BOOLEAN +Admit == /\ stage = "new" /\ capable /\ ~stopped /\ ~absent /\ boot = "original" + /\ intent' = TRUE /\ stage' = "admitted" + /\ UNCHANGED <> +Create == /\ stage = "admitted" /\ capable /\ ~stopped /\ ~absent /\ boot = "original" + /\ stage' = "created" + /\ UNCHANGED <> +\* Known refusal before dispatch may withdraw only the live capability. +Withdraw == /\ stage = "admitted" /\ capable /\ stage' = "withdrawn" + /\ intent' = FALSE /\ capable' = FALSE + /\ UNCHANGED <> +Record == /\ stage = "created" /\ capable + /\ recorded' = TRUE /\ stage' = "recorded" + /\ UNCHANGED <> +Start == /\ stage = "recorded" /\ capable /\ ~stopped /\ ~absent /\ boot = "original" + /\ starts' = starts + 1 /\ stage' = "started" /\ capable' = FALSE + /\ UNCHANGED <> +Attach == /\ stage = "started" /\ stage' = "attached" + /\ UNCHANGED <> +EOF == /\ stage \in {"started", "attached"} /\ ended' = TRUE /\ stage' = "ended" + /\ UNCHANGED <> +Crash == /\ stage \notin {"new", "abandoned", "retired", "withdrawn"} + /\ stage' = "abandoned" /\ capable' = FALSE + /\ UNCHANGED <> +\* Exact-boot proof is durable and survives later boot replacement. +StopBoot == /\ boot = "original" /\ ~stopped /\ stopped' = TRUE + /\ UNCHANGED <> +RemoveContainer == /\ ~absent /\ boot = "original" /\ absent' = TRUE + /\ UNCHANGED <> +\* Changed boot alone grants no cleanup proof, even when the new Engine says 404. +ReplaceBoot == /\ boot = "original" /\ boot' = "replacement" + /\ UNCHANGED <> +Retire == /\ intent /\ stage # "retired" /\ (stopped \/ absent \/ (RetireOnEOF /\ ended) \/ (WrongBoot /\ replacementStopped)) + /\ stage' = "retired" /\ capable' = FALSE + /\ UNCHANGED <> +ReplayStart == /\ Replay /\ stage = "abandoned" /\ recorded /\ starts = 1 + /\ stage' = "started" /\ starts' = 2 + /\ UNCHANGED <> +StopReplacement == /\ boot = "replacement" /\ ~replacementStopped /\ replacementStopped' = TRUE + /\ UNCHANGED <> +Next == Admit \/ Create \/ Withdraw \/ Record \/ Start \/ Attach \/ EOF \/ Crash \/ StopBoot \/ RemoveContainer \/ ReplaceBoot \/ StopReplacement \/ Retire \/ ReplayStart +Spec == Init /\ [][Next]_vars +WriteAhead == starts > 0 => intent /\ recorded +NoReplay == starts <= 1 +RetiredWithProof == stage = "retired" => stopped \/ absent +============================================================================= diff --git a/tests/models/tla/interactive-session/eof-retirement.cfg b/tests/models/tla/interactive-session/eof-retirement.cfg new file mode 100644 index 000000000..208054ea5 --- /dev/null +++ b/tests/models/tla/interactive-session/eof-retirement.cfg @@ -0,0 +1,6 @@ +CONSTANTS Replay = FALSE + RetireOnEOF = TRUE + WrongBoot = FALSE +SPECIFICATION Spec +INVARIANTS TypeOK WriteAhead NoReplay RetiredWithProof +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/interactive-session/negative.cfg b/tests/models/tla/interactive-session/negative.cfg new file mode 100644 index 000000000..dee425edf --- /dev/null +++ b/tests/models/tla/interactive-session/negative.cfg @@ -0,0 +1,6 @@ +CONSTANTS Replay = TRUE + RetireOnEOF = FALSE + WrongBoot = FALSE +SPECIFICATION Spec +INVARIANTS TypeOK WriteAhead NoReplay RetiredWithProof +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/interactive-session/positive.cfg b/tests/models/tla/interactive-session/positive.cfg new file mode 100644 index 000000000..d4539ccf5 --- /dev/null +++ b/tests/models/tla/interactive-session/positive.cfg @@ -0,0 +1,6 @@ +CONSTANTS Replay = FALSE + RetireOnEOF = FALSE + WrongBoot = FALSE +SPECIFICATION Spec +INVARIANTS TypeOK WriteAhead NoReplay RetiredWithProof +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/interactive-session/wrong-boot.cfg b/tests/models/tla/interactive-session/wrong-boot.cfg new file mode 100644 index 000000000..1a8fa0dfc --- /dev/null +++ b/tests/models/tla/interactive-session/wrong-boot.cfg @@ -0,0 +1,6 @@ +CONSTANTS Replay = FALSE + RetireOnEOF = FALSE + WrongBoot = TRUE +SPECIFICATION Spec +INVARIANTS TypeOK WriteAhead NoReplay RetiredWithProof +CHECK_DEADLOCK FALSE diff --git a/tests/native-authored-command-dispatch.test.ts b/tests/native-authored-command-dispatch.test.ts index 753455da9..011bb8ac7 100644 --- a/tests/native-authored-command-dispatch.test.ts +++ b/tests/native-authored-command-dispatch.test.ts @@ -140,7 +140,7 @@ test.each([ expect(value.code).toBe(1); expect(value.stdout + value.stderr).toContain("E_NATIVE_PROJECT_UNSUPPORTED"); expect(value.stdout + value.stderr).toContain( - "whole-project foreground up or restart, owner-mediated down, ps, single-service logs, noninteractive single-service exec or run, or explicit stored-generation down --recover on macOS" + "whole-project foreground up or restart, owner-mediated down, ps, single-service logs, single-service exec or noninteractive run, or explicit stored-generation down --recover on macOS" ); expect(await Bun.file(join(selected.root, "compiler-called")).exists()).toBe( false diff --git a/tests/native-authored-command.test.ts b/tests/native-authored-command.test.ts index dc2465e9e..9f53b27fc 100644 --- a/tests/native-authored-command.test.ts +++ b/tests/native-authored-command.test.ts @@ -693,7 +693,7 @@ if (process.platform !== "darwin") { env: { HACK_RUNTIME_BACKEND: "native" }, }) ).rejects.toThrow( - "requires whole-project foreground up or restart, owner-mediated down, ps, single-service logs, noninteractive single-service exec or run, or explicit stored-generation down --recover on macOS" + "requires whole-project foreground up or restart, owner-mediated down, ps, single-service logs, single-service exec or noninteractive run, or explicit stored-generation down --recover on macOS" ); expect(await readdir(join(selected.root, ".hack"))).toEqual([]); }); diff --git a/tests/native-interactive-exec.test.ts b/tests/native-interactive-exec.test.ts new file mode 100644 index 000000000..5a55d9f12 --- /dev/null +++ b/tests/native-interactive-exec.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { parseInteractiveReport } from "../src/backends/native-interactive-exec.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +const session = "00000000-0000-4000-8000-000000000001"; +test("interactive report is closed, value-free and preserves command status", () => { + expect( + parseInteractiveReport({ + version: 1, + outcome: "completed", + completion: { exit_code: 7, session, retained: true }, + }) + ).toEqual({ code: 7, session, retained: true }); + expect( + parseInteractiveReport({ + version: 1, + outcome: "refused", + code: "session_cleanup_uncertain", + session, + }) + ).toEqual({ + code: 1, + session, + retained: true, + failure: "session_cleanup_uncertain", + }); + for (const value of [ + { version: 2 }, + { version: 1, outcome: "refused", code: "private\ncanary", session }, + { + version: 1, + outcome: "refused", + code: "refused", + session: "private-canary", + }, + { + version: 1, + outcome: "refused", + code: "refused", + session, + message: "private-canary", + }, + { + version: 1, + outcome: "completed", + completion: { exit_code: 256, session, retained: true }, + }, + ]) { + expect(() => parseInteractiveReport(value)).toThrow( + "no request was replayed" + ); + } +}); + +test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( + "source and compiled frontend preserve fd3, reap the reader and restore the PTY", + async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "hack-interactive-frontend-")) + ); + try { + const python = Bun.which("python3"); + expect(python).not.toBeNull(); + if (!python) { + throw new Error("PTY contract requires python3"); + } + await writeFile( + join(root, "runtime"), + `#!${python}\n${await readFile(resolve("tests/fixtures/native-interactive-runtime.py"), "utf8")}`, + { mode: 0o700 } + ); + const source = resolve("tests/fixtures/native-interactive-frontend.ts"); + const compiled = join(root, "frontend"); + const env = privateCliEnv({ root }); + const commands = [ + [process.execPath, "build", "--compile", source, "--outfile", compiled], + [ + python, + resolve("tests/fixtures/native-interactive-frontend.py"), + root, + process.execPath, + source, + ], + [ + python, + resolve("tests/fixtures/native-interactive-frontend.py"), + root, + compiled, + ], + ]; + for (const cmd of commands) { + const child = Bun.spawn(cmd, { + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 90_000, + }); + const [out, err, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + expect(code, err + out).toBe(0); + if (cmd[0] === python) { + expect(out).toBe("Frontend PTY: four cases passed\n"); + } + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }, + 240_000 +); diff --git a/tests/native-interactive-launcher.test.ts b/tests/native-interactive-launcher.test.ts new file mode 100644 index 000000000..3758cb26e --- /dev/null +++ b/tests/native-interactive-launcher.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +// The launcher is a Linux guest artifact. macOS exercises it inside the real VM. +test.skipIf( + process.platform !== "linux" || !Bun.which("zig") || !Bun.which("python3") +)( + "interactive guest envelope never echoes private bytes and restores only on success", + async () => { + const root = await mkdtemp(join(tmpdir(), "hack-interactive-launcher-")); + try { + const binary = join(root, "launcher"); + const env = privateCliEnv({ root }); + const version = Bun.spawnSync(["zig", "version"], { + env, + stdout: "pipe", + stderr: "pipe", + }); + expect(version.exitCode).toBe(0); + expect(version.stdout.toString().trim()).toBe("0.15.2"); + for (const cmd of [ + [ + "zig", + "build-exe", + resolve("packages/runtime-core/guest/environment-launcher.zig"), + "-target", + process.arch === "arm64" ? "aarch64-linux-musl" : "x86_64-linux-musl", + "-O", + "ReleaseSmall", + `-femit-bin=${binary}`, + "--cache-dir", + join(root, "cache"), + "--global-cache-dir", + join(root, "global-cache"), + ], + [ + "python3", + resolve("tests/fixtures/environment-launcher-tty.py"), + binary, + ], + ]) { + const child = Bun.spawn(cmd, { + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 60_000, + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + expect(stderr).toBe(""); + expect(code).toBe(0); + if (cmd[0] === "python3") { + expect(stdout).toBe("PTY handshake: seven cases passed\n"); + } + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }, + 125_000 +); From 7396b6133ce8d08b7024e01bd66bce75e47094f8 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 10:32:53 +0000 Subject: [PATCH 02/13] fix(native): keep interactive launcher within publication budget --- .github/workflows/ci.yml | 1 + .../guest/environment-launcher.zig | 118 ++++++++++++++---- 2 files changed, 98 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ea6d903e..148c192f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -318,6 +318,7 @@ jobs: set -euo pipefail test "$(zig version)" = '0.15.2' command -v python3 + zig test packages/runtime-core/guest/environment-launcher.zig zig build-exe packages/runtime-core/guest/environment-launcher.zig -target x86_64-linux-musl -O ReleaseSmall -femit-bin="$RUNNER_TEMP/interactive-environment-launcher" python3 tests/fixtures/environment-launcher-tty.py "$RUNNER_TEMP/interactive-environment-launcher" - name: Check candidate formatting diff --git a/packages/runtime-core/guest/environment-launcher.zig b/packages/runtime-core/guest/environment-launcher.zig index cfa063501..a8aed8f8b 100644 --- a/packages/runtime-core/guest/environment-launcher.zig +++ b/packages/runtime-core/guest/environment-launcher.zig @@ -1,6 +1,34 @@ const std = @import("std"); const allocator = std.heap.page_allocator; +// Closed wire types avoid bringing a general-purpose JSON number converter into +// the bounded publication artifact. Zig's ordinary integer/string parsers also +// accept coercions (quoted numbers and byte arrays) which this protocol refuses. +const String = struct { + bytes: []const u8, + pub fn jsonParse(alloc: std.mem.Allocator, source: anytype, options: std.json.ParseOptions) !@This() { + return .{ .bytes = switch (try source.nextAllocMax(alloc, options.allocate.?, options.max_value_len.?)) { + .string, .allocated_string => |bytes| bytes, + else => return error.UnexpectedToken, + } }; + } +}; +const Values = std.json.ArrayHashMap(String); +const Integer = struct { + value: i64, + pub fn jsonParse(alloc: std.mem.Allocator, source: anytype, options: std.json.ParseOptions) !@This() { + const token = try source.nextAllocMax(alloc, .alloc_if_needed, options.max_value_len.?); + const text = switch (token) { + .number => |text| text, + .allocated_number => |text| text, + else => return error.UnexpectedToken, + }; + defer if (token == .allocated_number) alloc.free(text); + return .{ .value = try std.fmt.parseInt(i64, text, 10) }; + } +}; +const Fresh = struct { version: Integer, expires: Integer, values: Values }; + fn privateFile(path: []const u8, max: usize) ![]u8 { const fd = try std.posix.open(path, .{ .ACCMODE = .RDONLY, .NOFOLLOW = true, .CLOEXEC = true }, 0); const file = std.fs.File{ .handle = fd }; @@ -23,19 +51,15 @@ fn bootSeconds() !u64 { if (now.sec < 0) return error.Expired; return @intCast(now.sec); } -fn freshValues(value: std.json.Value, started: u64, now: u64) !std.json.ObjectMap { - if (value != .object or value.object.count() != 3) return error.Payload; - const version = value.object.get("version") orelse return error.Payload; - const expires = value.object.get("expires") orelse return error.Payload; - const values = value.object.get("values") orelse return error.Payload; - if (version != .integer or version.integer != 1 or expires != .integer or expires.integer <= 0 or values != .object or values.object.count() == 0 or values.object.count() > 256) return error.Payload; - const deadline: u64 = @intCast(expires.integer); +fn freshValues(value: Fresh, started: u64, now: u64) !std.StringArrayHashMapUnmanaged(String) { + if (value.version.value != 1 or value.expires.value <= 0 or value.values.map.count() == 0 or value.values.map.count() > 256) return error.Payload; + const deadline: u64 = @intCast(value.expires.value); if (deadline > (std.math.add(u64, started, 300) catch return error.Expired) or now >= deadline) return error.Expired; - var entries = values.object.iterator(); + var entries = value.values.map.iterator(); while (entries.next()) |entry| { - if (!keyValid(entry.key_ptr.*) or entry.value_ptr.* != .string or std.mem.indexOfScalar(u8, entry.value_ptr.string, 0) != null) return error.Payload; + if (!keyValid(entry.key_ptr.*) or std.mem.indexOfScalar(u8, entry.value_ptr.bytes, 0) != null) return error.Payload; } - return values.object; + return value.values.map; } /// Exec the selected process in this container after applying its environment, /// as Docker does: a name containing `/` is executed as given (relative to the @@ -95,14 +119,14 @@ fn freshExec(args: []const [:0]u8) !void { used += n; if (used > 65536) return error.Payload; } - const parsed = try std.json.parseFromSlice(std.json.Value, allocator, input[0..used], .{ .duplicate_field_behavior = .@"error" }); + const parsed = try std.json.parseFromSlice(Fresh, allocator, input[0..used], .{ .duplicate_field_behavior = .@"error" }); defer parsed.deinit(); const values = try freshValues(parsed.value, started, try bootSeconds()); var env = try std.process.getEnvMap(allocator); var entries = values.iterator(); while (entries.next()) |entry| { - if (!keyValid(entry.key_ptr.*) or entry.value_ptr.* != .string or std.mem.indexOfScalar(u8, entry.value_ptr.string, 0) != null) return error.Payload; - try env.put(entry.key_ptr.*, entry.value_ptr.string); + if (!keyValid(entry.key_ptr.*) or std.mem.indexOfScalar(u8, entry.value_ptr.bytes, 0) != null) return error.Payload; + try env.put(entry.key_ptr.*, entry.value_ptr.bytes); } _ = try freshValues(parsed.value, started, try bootSeconds()); // stdin was fully consumed to EOF; the command receives no secret input bytes. @@ -201,13 +225,13 @@ fn interactiveEnvelope(started: u64) !std.process.EnvMap { allocator.free(storage); } var fixed = std.heap.FixedBufferAllocator.init(storage); - const parsed = try std.json.parseFromSlice(std.json.Value, fixed.allocator(), decoded[0..decoded_length], .{ .duplicate_field_behavior = .@"error" }); + const parsed = try std.json.parseFromSlice(Fresh, fixed.allocator(), decoded[0..decoded_length], .{ .duplicate_field_behavior = .@"error" }); defer parsed.deinit(); const values = try freshValues(parsed.value, started, try bootSeconds()); var env = try std.process.getEnvMap(allocator); errdefer env.deinit(); var entries = values.iterator(); - while (entries.next()) |entry| try env.put(entry.key_ptr.*, entry.value_ptr.string); + while (entries.next()) |entry| try env.put(entry.key_ptr.*, entry.value_ptr.bytes); // The host is still gating keystrokes. Pending bytes mean a bad envelope, // not command input; never restore echo over them. var fds = [_]std.posix.pollfd{.{ .fd = 0, .events = std.posix.POLL.IN, .revents = 0 }}; @@ -234,13 +258,13 @@ fn run() !void { const payload = try privateFile(args[1], 32768); const expiry = try privateFile(args[2], 32); const deadline = try std.fmt.parseInt(u64, expiry, 10); - const parsed = try std.json.parseFromSlice(std.json.Value, allocator, payload, .{ .duplicate_field_behavior = .@"error" }); - if (parsed.value != .object or parsed.value.object.count() == 0 or parsed.value.object.count() > 256) return error.Payload; + const parsed = try std.json.parseFromSlice(Values, allocator, payload, .{ .duplicate_field_behavior = .@"error" }); + if (parsed.value.map.count() == 0 or parsed.value.map.count() > 256) return error.Payload; var env = try std.process.getEnvMap(allocator); - var entries = parsed.value.object.iterator(); + var entries = parsed.value.map.iterator(); while (entries.next()) |entry| { - if (!keyValid(entry.key_ptr.*) or entry.value_ptr.* != .string or std.mem.indexOfScalar(u8, entry.value_ptr.string, 0) != null) return error.Payload; - try env.put(entry.key_ptr.*, entry.value_ptr.string); + if (!keyValid(entry.key_ptr.*) or std.mem.indexOfScalar(u8, entry.value_ptr.bytes, 0) != null) return error.Payload; + try env.put(entry.key_ptr.*, entry.value_ptr.bytes); } const now = try std.posix.clock_gettime(.BOOTTIME); if (now.sec < 0 or @as(u64, @intCast(now.sec)) >= deadline) return error.Expired; @@ -264,7 +288,10 @@ test "fresh envelope refuses expired invalid and extra fields" { "{\"version\":1,\"expires\":130,\"values\":{},\"extra\":true}", }; for (fixtures, 0..) |fixture, i| { - const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, fixture, .{ .duplicate_field_behavior = .@"error" }); + const parsed = std.json.parseFromSlice(Fresh, std.testing.allocator, fixture, .{ .duplicate_field_behavior = .@"error" }) catch |err| { + if (i == 0) return err; + continue; + }; defer parsed.deinit(); if (i == 0) { _ = try freshValues(parsed.value, 100, 101); @@ -273,3 +300,52 @@ test "fresh envelope refuses expired invalid and extra fields" { } } } + +test "wire schema refuses numeric coercions duplicate fields and non-string values" { + const bad_numbers = [_][]const u8{ "\"1\"", "1.0", "1e0", "true", "null", "9223372036854775808", "-9223372036854775809" }; + for (bad_numbers) |number| { + for ([_][]const u8{ "version", "expires" }) |field| { + const other = if (std.mem.eql(u8, field, "version")) "\"expires\":130" else "\"version\":1"; + const input = try std.fmt.allocPrint(std.testing.allocator, "{{\"{s}\":{s},{s},\"values\":{{\"TOKEN\":\"synthetic\"}}}}", .{ field, number, other }); + defer std.testing.allocator.free(input); + if (std.json.parseFromSlice(Fresh, std.testing.allocator, input, .{ .duplicate_field_behavior = .@"error" })) |parsed| { + parsed.deinit(); + return error.ExpectedRefusal; + } else |_| {} + } + } + for ([_][]const u8{ + "{\"TOKEN\":[97]}", "{\"TOKEN\":{}}", "{\"TOKEN\":null}", + "{\"TOKEN\":true}", "{\"TOKEN\":1}", "{\"TOKEN\":1.0}", + "{\"TOKEN\":\"first\",\"TOKEN\":\"second\"}", "[]", + }) |input| { + if (std.json.parseFromSlice(Values, std.testing.allocator, input, .{ .duplicate_field_behavior = .@"error" })) |parsed| { + parsed.deinit(); + return error.ExpectedRefusal; + } else |_| {} + } + for ([_][]const u8{ + "{\"version\":1,\"version\":1,\"expires\":130,\"values\":{\"TOKEN\":\"synthetic\"}}", + "{\"version\":1,\"expires\":130,\"values\":{\"TOKEN\":\"synthetic\"},\"extra\":0}", + "{\"version\":1,\"values\":{\"TOKEN\":\"synthetic\"}}", + }) |input| { + if (std.json.parseFromSlice(Fresh, std.testing.allocator, input, .{ .duplicate_field_behavior = .@"error" })) |parsed| { + parsed.deinit(); + return error.ExpectedRefusal; + } else |_| {} + } +} + +test "mounted and fresh schemas preserve empty escaped and unicode strings" { + const values = "{\"EMPTY\":\"\",\"TOKEN\":\"synthetic\\n\\u0010\\u0011\\u00e9\"}"; + const expected = "synthetic\n\x10\x11\xc3\xa9"; + const mounted = try std.json.parseFromSlice(Values, std.testing.allocator, values, .{ .duplicate_field_behavior = .@"error" }); + defer mounted.deinit(); + try std.testing.expectEqualStrings("", mounted.value.map.get("EMPTY").?.bytes); + try std.testing.expectEqualStrings(expected, mounted.value.map.get("TOKEN").?.bytes); + const fresh = try std.json.parseFromSlice(Fresh, std.testing.allocator, "{\"version\":1,\"expires\":130,\"values\":" ++ values ++ "}", .{ .duplicate_field_behavior = .@"error" }); + defer fresh.deinit(); + const checked = try freshValues(fresh.value, 100, 101); + try std.testing.expectEqualStrings("", checked.get("EMPTY").?.bytes); + try std.testing.expectEqualStrings(expected, checked.get("TOKEN").?.bytes); +} From 28d784720a5b2e22849059e08da5e76528b0b9b4 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 10:38:16 +0000 Subject: [PATCH 03/13] fix(native): align interactive exec macOS feature gates --- packages/runtime-core/src/provider/engine.rs | 4 ++-- packages/runtime-core/src/provider/lifecycle.rs | 2 +- packages/runtime-core/src/provider/mod.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/runtime-core/src/provider/engine.rs b/packages/runtime-core/src/provider/engine.rs index 6573feb67..271fc988d 100644 --- a/packages/runtime-core/src/provider/engine.rs +++ b/packages/runtime-core/src/provider/engine.rs @@ -582,8 +582,8 @@ impl<'a> Engine<'a> { ) } - /// One live-owner command keeps its original budget through every engine query. - #[cfg(all(target_os = "macos", feature = "native-config-plan"))] + /// One service command keeps its original budget through every engine query. + #[cfg(target_os = "macos")] pub(in crate::provider) fn connect_command_until( candidate: &'a Candidate, deadline: Instant, diff --git a/packages/runtime-core/src/provider/lifecycle.rs b/packages/runtime-core/src/provider/lifecycle.rs index 5e17bd3d7..4f73dc821 100644 --- a/packages/runtime-core/src/provider/lifecycle.rs +++ b/packages/runtime-core/src/provider/lifecycle.rs @@ -327,7 +327,7 @@ pub(super) struct ObservedGuest<'a> { } impl<'a> ObservedGuest<'a> { - #[cfg(all(target_os = "macos", feature = "native-config-plan"))] + #[cfg(target_os = "macos")] pub(super) fn boot_id(&self) -> Result<&str, CandidateError> { self.owner.guest_boot_id.as_deref().ok_or_else(|| { CandidateError::new("runtime_changed", "Observed guest boot is unavailable.") diff --git a/packages/runtime-core/src/provider/mod.rs b/packages/runtime-core/src/provider/mod.rs index c93d7fbc8..c4e75ac02 100644 --- a/packages/runtime-core/src/provider/mod.rs +++ b/packages/runtime-core/src/provider/mod.rs @@ -42,7 +42,7 @@ mod image_load; pub mod image_platform; pub mod image_policy; pub mod image_reference; -#[cfg(any(target_os = "macos", test))] +#[cfg(any(all(target_os = "macos", feature = "native-config-plan"), test))] mod interactive_handoff; #[cfg(any(target_os = "macos", test))] pub mod interactive_session; From fdc717caad56d7aeef368a0f7a222116f15c6c4e Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 10:51:31 +0000 Subject: [PATCH 04/13] fix(native): set interactive terminal size before exec starts --- docs/guides/native-candidate.md | 2 + docs/reference/native-authored-commands.md | 3 ++ .../src/provider/engine/interactive_exec.rs | 18 ++++--- .../src/provider/engine/relay_exec.rs | 41 ++++++++++++---- .../src/provider/graph/interactive_exec.rs | 15 +++--- .../src/provider/graph/native/foreground.rs | 26 +++++++--- .../provider/graph/native/foreground/tests.rs | 47 +++++++++++++++++++ .../src/provider/graph/native/runtime/exec.rs | 8 +++- .../src/provider/graph/service_exec.rs | 8 +++- .../src/provider/interactive_terminal.rs | 6 ++- tests/fixtures/native-interactive-terminal.py | 2 + 11 files changed, 141 insertions(+), 35 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 5611eaeb9..05ed89dcc 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -4024,6 +4024,8 @@ changed boot alone are not cleanup proof. No uncertain command is replayed. On the built current-branch runtime and CLI, exercise authored and adopted services: - A shell with foreground/background jobs; Ctrl-C, Ctrl-Z, `fg` and resize. +- `hack exec SERVICE -- stty size` reports the host terminal's dimensions on its + first output, before any subsequent window resize. - Ctrl-P Ctrl-Q disconnect, retaining its UUID and cleanup remedy. - A background process that survives shell exit; receipt remains until `down`. - `down` and restart from another terminal while the session is active; the owner diff --git a/docs/reference/native-authored-commands.md b/docs/reference/native-authored-commands.md index 211c17374..800eb0c06 100644 --- a/docs/reference/native-authored-commands.md +++ b/docs/reference/native-authored-commands.md @@ -536,6 +536,9 @@ run `hack down` for a project with retained sessions to release its receipts. This includes normally exited commands whose background descendants remain unproven. The command's known exit status is still preserved. +The initial terminal size is supplied before the guest command starts. +If the host reports an unknown (zero) dimension, Hack uses 24 rows or 80 columns +for that dimension until the terminal supplies a size. Terminal output combines the command's stdout and stderr. Ctrl-C, Ctrl-Z and other terminal bytes go to the guest PTY; window-size changes are forwarded. Docker's Ctrl-P Ctrl-Q sequence disconnects the transport. Disconnecting does not prove diff --git a/packages/runtime-core/src/provider/engine/interactive_exec.rs b/packages/runtime-core/src/provider/engine/interactive_exec.rs index 03e1e5be1..0493567b8 100644 --- a/packages/runtime-core/src/provider/engine/interactive_exec.rs +++ b/packages/runtime-core/src/provider/engine/interactive_exec.rs @@ -33,18 +33,24 @@ pub(in crate::provider) struct Attached { pub stream: UnixStream, } +#[cfg(target_os = "macos")] +pub(in crate::provider) struct Command<'a> { + pub argv: &'a [String], + pub workdir: Option<&'a str>, + pub console_size: [u16; 2], +} + #[cfg(target_os = "macos")] impl Engine<'_> { pub(in crate::provider) fn interactive_start( &self, candidate: &Candidate, binding: Binding, - argv: &[String], - workdir: Option<&str>, + command: Command<'_>, deadline: Instant, fresh: &dyn Fn() -> Result<(), CandidateError>, ) -> Result { - if self.cleanup_only || !binding.valid() { + if self.cleanup_only || !binding.valid() || command.console_size.contains(&0) { return Err(uncertain()); } fresh()?; @@ -63,8 +69,8 @@ impl Engine<'_> { }; let result: Result = (|| { effect()?; - let mut body = serde_json::json!({"AttachStdin":true,"AttachStdout":true,"AttachStderr":true,"Tty":true,"Privileged":false,"DetachKeys":"ctrl-p,ctrl-q","Cmd":argv}); - if let Some(path) = workdir { + let mut body = serde_json::json!({"AttachStdin":true,"AttachStdout":true,"AttachStderr":true,"Tty":true,"ConsoleSize":command.console_size,"Privileged":false,"DetachKeys":"ctrl-p,ctrl-q","Cmd":command.argv}); + if let Some(path) = command.workdir { body["WorkingDir"] = Value::String(path.into()); } let transport = Transport::new(&self.guest.engine_socket()?, remaining(deadline)?)?; @@ -94,7 +100,7 @@ impl Engine<'_> { .map_err(|_| uncertain())?; // Recheck after connect, immediately before the non-replayed start. effect()?; - super::relay_exec::start_mode(&mut stream, id, deadline, true)?; + super::relay_exec::start_mode(&mut stream, id, deadline, Some(command.console_size))?; fresh()?; self.guest.verify()?; Ok(Attached { receipt, stream }) diff --git a/packages/runtime-core/src/provider/engine/relay_exec.rs b/packages/runtime-core/src/provider/engine/relay_exec.rs index a4790b839..f78b3d03f 100644 --- a/packages/runtime-core/src/provider/engine/relay_exec.rs +++ b/packages/runtime-core/src/provider/engine/relay_exec.rs @@ -122,18 +122,20 @@ pub(super) fn start( id: &str, deadline: Instant, ) -> Result<(), CandidateError> { - start_mode(stream, id, deadline, false) + start_mode(stream, id, deadline, None) } pub(super) fn start_mode( stream: &mut UnixStream, id: &str, deadline: Instant, - tty: bool, + console_size: Option<[u16; 2]>, ) -> Result<(), CandidateError> { - let body = if tty { - r#"{"Detach":false,"Tty":true}"# - } else { - r#"{"Detach":false,"Tty":false}"# + let body = match console_size { + Some([rows, cols]) if rows > 0 && cols > 0 => { + format!(r#"{{"Detach":false,"Tty":true,"ConsoleSize":[{rows},{cols}]}}"#) + } + Some(_) => return Err(refused()), + None => r#"{"Detach":false,"Tty":false}"#.into(), }; let request = format!( "POST /v1.53/exec/{id}/start HTTP/1.1\r\nHost: hack-local\r\nConnection: Upgrade\r\nUpgrade: tcp\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", @@ -250,18 +252,18 @@ mod tests { bytes } #[test] - fn interactive_upgrade_preserves_output_in_the_header_read() { + fn interactive_start_sets_console_size_before_coalesced_first_output() { let (mut client, mut server) = UnixStream::pair().unwrap(); let worker = std::thread::spawn(move || { let wire = request(&mut server); - assert!(wire.ends_with(br#"{"Detach":false,"Tty":true}"#)); + assert!(wire.ends_with(br#"{"Detach":false,"Tty":true,"ConsoleSize":[43,91]}"#)); server.write_all(b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\nfirst-output").unwrap(); }); start_mode( &mut client, &"a".repeat(64), Instant::now() + Duration::from_secs(2), - true, + Some([43, 91]), ) .unwrap(); let mut output = Vec::new(); @@ -270,6 +272,26 @@ mod tests { worker.join().unwrap(); } #[test] + fn invalid_initial_console_size_sends_no_start_request() { + for size in [[0, 91], [43, 0], [0, 0]] { + let (mut client, mut server) = UnixStream::pair().unwrap(); + assert!( + start_mode( + &mut client, + &"a".repeat(64), + Instant::now() + Duration::from_secs(1), + Some(size) + ) + .is_err() + ); + server.set_nonblocking(true).unwrap(); + assert_eq!( + server.read(&mut [0u8; 1]).unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + } + } + #[test] fn real_upgrade_keeps_private_stdin_out_of_http_and_reads_fragmented_output() { let (mut client, mut server) = UnixStream::pair().unwrap(); let worker = std::thread::spawn(move || { @@ -277,6 +299,7 @@ mod tests { assert!(request.starts_with( format!("POST /v1.53/exec/{}/start HTTP/1.1", "a".repeat(64)).as_bytes() )); + assert!(request.ends_with(br#"{"Detach":false,"Tty":false}"#)); assert!(!request.windows(8).any(|v| v == b"HKRP0001")); server .write_all(b"HTTP/1.1 101 UPGRADED\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\n") diff --git a/packages/runtime-core/src/provider/graph/interactive_exec.rs b/packages/runtime-core/src/provider/graph/interactive_exec.rs index 434aaca75..8097c8af9 100644 --- a/packages/runtime-core/src/provider/graph/interactive_exec.rs +++ b/packages/runtime-core/src/provider/graph/interactive_exec.rs @@ -118,18 +118,14 @@ fn native( drop(payload); leash.check()?; let _ = interactive_session::reconcile_observed(candidate); + // Capture before either owner admission or Engine create/start, so even a + // command which exits immediately observes the initial terminal dimensions. + let (rows, cols) = terminal.size()?; + let console_size = [rows, cols]; let admission = if options.native { #[cfg(feature = "native-config-plan")] { - super::native::foreground::interactive( - candidate, - options.run, - options.service, - options.argv, - options.workdir, - (options.owner, options.plan, options.namespace), - &leash, - ) + super::native::foreground::interactive(candidate, options, console_size, &leash) } #[cfg(not(feature = "native-config-plan"))] { @@ -161,6 +157,7 @@ fn native( workdir: options.workdir, timeout: Duration::from_secs(30), }, + console_size, managed.as_ref(), &|| leash.check(), ) diff --git a/packages/runtime-core/src/provider/graph/native/foreground.rs b/packages/runtime-core/src/provider/graph/native/foreground.rs index b9eaad96c..8ccd70e00 100644 --- a/packages/runtime-core/src/provider/graph/native/foreground.rs +++ b/packages/runtime-core/src/provider/graph/native/foreground.rs @@ -71,12 +71,18 @@ struct Request { exec: Option, #[serde(default, skip_serializing_if = "Option::is_none")] one_off: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + console_size: Option<[u16; 2]>, } impl Request { fn validate(&self, review: &native_input::Review) -> Result<(), CandidateError> { if self.version != 2 || self.run != review.scope().run || self.review != review.review_id() + || match self.console_size { + Some(size) => self.action != Action::InteractiveExec || size.contains(&0), + None => self.action == Action::InteractiveExec, + } || match (&self.action, &self.logs, &self.exec, &self.one_off) { (Action::Logs, Some(logs), None, None) => !logs.valid(), (Action::Exec | Action::InteractiveExec, None, Some(exec), None) => !exec.valid(), @@ -410,7 +416,13 @@ pub fn serve_with_storage_tool( } read_pin.verify(candidate) }; - match runtime::interactive_exec(candidate, &receipt, selected, &check) { + match runtime::interactive_exec( + candidate, + &receipt, + selected, + request.console_size.ok_or_else(refused)?, + &check, + ) { Ok(session) => { attached = Some(session.stream); Ok(Outcome::Interactive { @@ -581,13 +593,13 @@ pub fn one_off( /// connected fd. The caller streams without retaining an owner/provider lease. pub(in crate::provider) fn interactive( candidate: &Candidate, - run: &str, - service: &str, - argv: &[String], - workdir: Option<&str>, - pins: (&str, &str, &str), + options: &crate::provider::graph::interactive_exec::Options<'_>, + console_size: [u16; 2], leash: &crate::provider::interactive_terminal::Leash, ) -> Result { + let (run, service, argv, workdir) = + (options.run, options.service, options.argv, options.workdir); + let pins = (options.owner, options.plan, options.namespace); let selected = runtime::ExecSelection { service: service.into(), argv: argv.to_vec(), @@ -615,6 +627,7 @@ pub(in crate::provider) fn interactive( run: run.into(), review: review.review_id().into(), action: Action::InteractiveExec, + console_size: Some(console_size), logs: None, exec: Some(selected.clone()), one_off: None, @@ -665,6 +678,7 @@ fn request_selected( run: run.into(), review: review.review_id().into(), action, + console_size: None, logs: selected.clone(), exec: exec_selected.clone(), one_off: one_off_selected.clone(), diff --git a/packages/runtime-core/src/provider/graph/native/foreground/tests.rs b/packages/runtime-core/src/provider/graph/native/foreground/tests.rs index c4baa6efd..4fc48a4a0 100644 --- a/packages/runtime-core/src/provider/graph/native/foreground/tests.rs +++ b/packages/runtime-core/src/provider/graph/native/foreground/tests.rs @@ -1037,6 +1037,53 @@ fn finite_logs_wire_authenticates_the_live_native_owner_and_exact_member() { publication.finish().unwrap(); assert!(logs(&fixture.candidate, RUN, "web", 17).is_err()); } +#[test] +fn initial_console_size_is_required_only_for_interactive_owner_requests() { + let fixture = Fixture::new(); + let receipt = fixture.receipt(); + let wire = json!({ + "version": 2, "kind": "native-graph-control", "run": RUN, + "review": receipt.review.review_id(), "action": "interactive-exec", + "exec": {"service":"web", "argv":["stty", "size"], "workdir":null}, + "console_size": [43, 91] + }); + let input: Request = serde_json::from_value(wire.clone()).unwrap(); + input.validate(&receipt.review).unwrap(); + assert_eq!(input.console_size, Some([43, 91])); + assert_eq!( + serde_json::to_value(input).unwrap()["console_size"], + json!([43, 91]) + ); + for size in [ + json!(null), + json!([0, 91]), + json!([43, 0]), + json!([43]), + json!([43, 91, 1]), + json!([-1, 91]), + json!([65536, 91]), + ] { + let mut invalid = wire.clone(); + invalid["console_size"] = size; + if let Ok(input) = serde_json::from_value::(invalid) { + assert!(input.validate(&receipt.review).is_err()); + } + } + let mut finite = wire; + finite["action"] = json!("exec"); + assert!( + serde_json::from_value::(finite.clone()) + .unwrap() + .validate(&receipt.review) + .is_err() + ); + finite.as_object_mut().unwrap().remove("console_size"); + serde_json::from_value::(finite) + .unwrap() + .validate(&receipt.review) + .unwrap(); +} + #[test] fn finite_exec_wire_authenticates_owner_member_and_binary_nonzero_completion() { let fixture = Fixture::new(); diff --git a/packages/runtime-core/src/provider/graph/native/runtime/exec.rs b/packages/runtime-core/src/provider/graph/native/runtime/exec.rs index 6d40fdb08..e3d9b2316 100644 --- a/packages/runtime-core/src/provider/graph/native/runtime/exec.rs +++ b/packages/runtime-core/src/provider/graph/native/runtime/exec.rs @@ -178,6 +178,7 @@ pub(in crate::provider::graph::native) fn interactive( candidate: &Candidate, expected: &Receipt, selected: &ExecSelection, + console_size: [u16; 2], guard: &dyn Fn() -> Result<(), CandidateError>, ) -> Result { use crate::provider::interactive_session::{Backend as SessionBackend, Binding}; @@ -236,8 +237,11 @@ pub(in crate::provider::graph::native) fn interactive( generation: generation.clone(), service: selected.service.clone(), }, - &selected.argv, - selected.workdir.as_deref(), + crate::provider::engine::interactive_exec::Command { + argv: &selected.argv, + workdir: selected.workdir.as_deref(), + console_size, + }, deadline, &fresh, ) diff --git a/packages/runtime-core/src/provider/graph/service_exec.rs b/packages/runtime-core/src/provider/graph/service_exec.rs index 0a0412051..d768441bf 100644 --- a/packages/runtime-core/src/provider/graph/service_exec.rs +++ b/packages/runtime-core/src/provider/graph/service_exec.rs @@ -71,6 +71,7 @@ fn validate(options: &ServiceExecOptions<'_>) -> Result<(), CandidateError> { pub(super) fn interactive( candidate: &Candidate, options: &ServiceExecOptions<'_>, + console_size: [u16; 2], managed: Option<&crate::provider::managed_environment::Managed>, live: &dyn Fn() -> Result<(), CandidateError>, ) -> Result { @@ -182,8 +183,11 @@ pub(super) fn interactive( generation: generation.clone(), service: options.service.to_owned(), }, - &argv, - options.workdir, + crate::provider::engine::interactive_exec::Command { + argv: &argv, + workdir: options.workdir, + console_size, + }, deadline, &fresh, )?; diff --git a/packages/runtime-core/src/provider/interactive_terminal.rs b/packages/runtime-core/src/provider/interactive_terminal.rs index 0dda5ffd3..854ff66c7 100644 --- a/packages/runtime-core/src/provider/interactive_terminal.rs +++ b/packages/runtime-core/src/provider/interactive_terminal.rs @@ -312,7 +312,10 @@ impl Terminal { if unsafe { libc::ioctl(self.file.as_raw_fd(), libc::TIOCGWINSZ, &mut size) } != 0 { return Err(refused()); } - Ok((size.ws_row.max(1), size.ws_col.max(1))) + Ok(( + if size.ws_row == 0 { 24 } else { size.ws_row }, + if size.ws_col == 0 { 80 } else { size.ws_col }, + )) } pub fn restore(&mut self) -> Result<(), CandidateError> { if self.raw { @@ -505,6 +508,7 @@ mod tests { let mut report = File::from(control.try_clone().unwrap()); let mut engine = unsafe { UnixStream::from_raw_fd(engine) }; let mut terminal = Terminal::open().unwrap(); + assert_eq!(terminal.size().unwrap(), (24, 80)); let (leash, payload) = Leash::receive(control).unwrap(); assert!(payload.is_empty()); let mut resized = false; diff --git a/tests/fixtures/native-interactive-terminal.py b/tests/fixtures/native-interactive-terminal.py index c002a02ff..c10a7a872 100644 --- a/tests/fixtures/native-interactive-terminal.py +++ b/tests/fixtures/native-interactive-terminal.py @@ -14,6 +14,8 @@ def run(mode): master, slave = pty.openpty() + # Unknown dimensions must produce a usable default before admission. + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 0, 0, 0, 0)) original = termios.tcgetattr(slave) control, child_control = socket.socketpair() engine, child_engine = socket.socketpair() From 180fd515896997e606db158798cd676868cc9841 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:10:50 +0000 Subject: [PATCH 05/13] test(native): verify interactive child key containment --- docs/guides/v5-release-notes.md | 5 +++++ tests/fixtures/native-interactive-frontend.py | 6 ++++- tests/fixtures/native-interactive-runtime.py | 3 +++ tests/native-interactive-exec.test.ts | 22 ++++++++++++++++--- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/docs/guides/v5-release-notes.md b/docs/guides/v5-release-notes.md index 52f4d0556..da519bd0c 100644 --- a/docs/guides/v5-release-notes.md +++ b/docs/guides/v5-release-notes.md @@ -147,6 +147,11 @@ Start with a disposable project. See [Native config import](../reference/native-config-import.md) and the staged plan in [Qualify a project on v5](v5-dogfooding.md). +### Prerelease behavior changes + +Plain native `hack logs --follow` output omits the service prefix (such as `web | `) +and writes both service stdout and stderr to the CLI's stdout, so pipes receive both. + ## Retired and unsupported | Retired in v5 | Use instead | diff --git a/tests/fixtures/native-interactive-frontend.py b/tests/fixtures/native-interactive-frontend.py index 2e0f4a1a2..375ea957d 100644 --- a/tests/fixtures/native-interactive-frontend.py +++ b/tests/fixtures/native-interactive-frontend.py @@ -1,5 +1,6 @@ """Private controlling PTY for the real Bun frontend, including compiled Bun.""" import fcntl +import json import os import pty import select @@ -10,7 +11,9 @@ import time root = sys.argv[1] -command = sys.argv[2:] +protected_keys = json.loads(sys.argv[2]) +command = sys.argv[3:] +assert all(os.environ.get(key) == "synthetic-parent-key-canary" for key in protected_keys) for mode in ("normal", "child-kill", "frontend-kill", "cancel"): for name in ("reader.pid", "reader.closed"): try: @@ -55,6 +58,7 @@ def session(): while select.select([master], [], [], 0)[0]: output.extend(os.read(master, 8192)) assert b"synthetic-private-envelope-canary" not in output + assert b"synthetic-parent-key-canary" not in output if mode != "frontend-kill": assert b"session_cleanup_uncertain: session 00000000-0000-4000-8000-000000000001" in output, repr(output) assert b"hack down --path" in output and b"--branch 'topic'" in output diff --git a/tests/fixtures/native-interactive-runtime.py b/tests/fixtures/native-interactive-runtime.py index 2469437bc..2bab9885d 100644 --- a/tests/fixtures/native-interactive-runtime.py +++ b/tests/fixtures/native-interactive-runtime.py @@ -8,6 +8,9 @@ import termios import tty +protected_keys = ["__PROTECTED_KEYS__"] # Replaced by the test from the production key set. +assert "__PROTECTED_KEYS__" not in protected_keys +assert not any(key in os.environ for key in protected_keys) session = "00000000-0000-4000-8000-000000000001" if "interactive-sessions" in sys.argv: print(json.dumps({"version": 1, "sessions": [session]})) diff --git a/tests/native-interactive-exec.test.ts b/tests/native-interactive-exec.test.ts index 5a55d9f12..fbc9ca783 100644 --- a/tests/native-interactive-exec.test.ts +++ b/tests/native-interactive-exec.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { parseInteractiveReport } from "../src/backends/native-interactive-exec.ts"; +import { HACK_KEY_MATERIAL_VARIABLES } from "../src/lib/child-environment.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; const session = "00000000-0000-4000-8000-000000000001"; @@ -56,7 +57,7 @@ test("interactive report is closed, value-free and preserves command status", () }); test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( - "source and compiled frontend preserve fd3, reap the reader and restore the PTY", + "source and compiled frontend contain keys, preserve fd3, reap the reader and restore the PTY", async () => { const root = await realpath( await mkdtemp(join(tmpdir(), "hack-interactive-frontend-")) @@ -67,9 +68,14 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( if (!python) { throw new Error("PTY contract requires python3"); } + const protectedKeys = JSON.stringify([...HACK_KEY_MATERIAL_VARIABLES]); + const runtimeSource = await readFile( + resolve("tests/fixtures/native-interactive-runtime.py"), + "utf8" + ); await writeFile( join(root, "runtime"), - `#!${python}\n${await readFile(resolve("tests/fixtures/native-interactive-runtime.py"), "utf8")}`, + `#!${python}\n${runtimeSource.replace('["__PROTECTED_KEYS__"]', protectedKeys)}`, { mode: 0o700 } ); const source = resolve("tests/fixtures/native-interactive-frontend.ts"); @@ -81,6 +87,7 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( python, resolve("tests/fixtures/native-interactive-frontend.py"), root, + protectedKeys, process.execPath, source, ], @@ -88,12 +95,21 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( python, resolve("tests/fixtures/native-interactive-frontend.py"), root, + protectedKeys, compiled, ], ]; for (const cmd of commands) { const child = Bun.spawn(cmd, { - env, + env: { + ...env, + ...Object.fromEntries( + [...HACK_KEY_MATERIAL_VARIABLES].map((name) => [ + name, + "synthetic-parent-key-canary", + ]) + ), + }, stdin: "ignore", stdout: "pipe", stderr: "pipe", From 8821da581e1d4d9eb021fb905781f00df0a6be7d Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:38:10 +0000 Subject: [PATCH 06/13] fix(native): scope interactive env delivery to selected service --- docs/guides/native-candidate.md | 6 +- .../src/provider/graph/service_exec.rs | 69 +++++++++++++++---- 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 05ed89dcc..201f25fc5 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -4011,7 +4011,9 @@ This does not isolate values from other processes running as the same container user, which already share access to the command's environment. Interactive managed-env exec refuses if fresh delivery is unavailable; it does -not reuse mounted startup values. Finite exec retains its existing behavior. +not reuse mounted startup values. This requirement applies only to the selected +service: a peer service with no managed values still uses ordinary interactive exec. +Finite exec retains its existing behavior. An interactive command's exit code does not prove its descendants are gone. Hack retains a value-free session receipt and prints `session_cleanup_uncertain` with @@ -4037,6 +4039,8 @@ On the built current-branch runtime and CLI, exercise authored and adopted servi - Fresh adopted env with a synthetic canary and detach-control bytes; no private bytes echo, the intended command receives the values, and an old launcher refuses before delivery. +- In a mixed adopted project, interactive exec also works on a service without + managed values while another service has a managed environment. Linux PTY and fake-Engine tests do not qualify these real Engine/VM effects. diff --git a/packages/runtime-core/src/provider/graph/service_exec.rs b/packages/runtime-core/src/provider/graph/service_exec.rs index d768441bf..227a194f6 100644 --- a/packages/runtime-core/src/provider/graph/service_exec.rs +++ b/packages/runtime-core/src/provider/graph/service_exec.rs @@ -82,12 +82,6 @@ pub(super) fn interactive( let deadline = Instant::now() + options.timeout; let engine = Engine::connect_command_until(candidate, deadline)?; let (receipt, root) = load(candidate, &engine, options.run)?; - if receipt.environment_attached && managed.is_none() { - return Err(error( - "interactive_exec_environment_required", - "Interactive exec requires fresh selected-service environment delivery; no startup-environment fallback was attempted.", - )); - } super::startup::require_dependency_rebind_complete(&root, &receipt)?; let pending = root .join("state.pending") @@ -100,6 +94,7 @@ pub(super) fn interactive( .filter(|s| crate::provider::engine::log_follow::valid_timestamp(s)) .ok_or_else(refused)? .to_owned(); + let mounted_slot = interactive_environment_slot(&container, managed.is_some())?; let slot = if receipt.environment_attached { crate::provider::environment_recovery::active_exec_slot( candidate, @@ -107,17 +102,11 @@ pub(super) fn interactive( &receipt.run, options.service, &resource.name, - mounted_environment_slot(&container)?.as_deref(), + mounted_slot.as_deref(), )? } else { None }; - if managed.is_none() && slot.is_some() && !options.argv[0].starts_with('/') { - #[cfg(feature = "environment-launcher")] - require_current_launcher(&container, &super::launcher::current_source())?; - #[cfg(not(feature = "environment-launcher"))] - return Err(refused()); - } let argv = if let Some(managed) = managed { #[cfg(feature = "environment-launcher")] { @@ -353,6 +342,24 @@ fn require_current_launcher(container: &Value, source: &str) -> Result<(), Candi Ok(()) } +/// A peer service's allocation cannot require fresh delivery for this container. +/// Attached values still require a fresh envelope; partial mounts never count as absence. +#[cfg(any(target_os = "macos", test))] +fn interactive_environment_slot( + container: &Value, + fresh: bool, +) -> Result, CandidateError> { + let slot = mounted_environment_slot(container)?; + managed_exec_argv(container, slot.as_deref(), &["/bin/true".into()])?; + if slot.is_some() && !fresh { + return Err(error( + "interactive_exec_environment_required", + "Interactive exec requires fresh selected-service environment delivery; no startup-environment fallback was attempted.", + )); + } + Ok(slot) +} + /// Infer only from the exact inspected container, never historical run inventory. fn mounted_environment_slot(container: &Value) -> Result, CandidateError> { let mounts = container["HostConfig"]["Mounts"] @@ -513,6 +520,42 @@ mod tests { assert!(managed_exec_argv(&plain, Some(slot), &args).is_err()); } #[test] + fn interactive_fresh_delivery_depends_on_selected_container_mounts() { + let slot = format!( + "hack-env-lease-dddddddd-dddd-4ddd-8ddd-dddddddddddd-{}", + "a".repeat(32) + ); + let managed = json!({"HostConfig":{"Mounts":[ + {"Type":"bind","Source":format!("/storage/hack-environment-launcher/{}", "a".repeat(64)),"Target":"/run/hack-environment-launcher","ReadOnly":true}, + {"Type":"bind","Source":format!("/run/{slot}/values.json"),"Target":"/run/hack-environment.json","ReadOnly":true}, + {"Type":"bind","Source":format!("/run/{slot}/expires"),"Target":"/run/hack-environment.expires","ReadOnly":true} + ]}}); + let plain = json!({"HostConfig":{"Mounts":[]}}); + // Both services may share a graph whose environment_attached bit is true. + assert_eq!(interactive_environment_slot(&plain, false).unwrap(), None); + assert_eq!( + interactive_environment_slot(&managed, false) + .unwrap_err() + .code, + "interactive_exec_environment_required" + ); + assert_eq!( + interactive_environment_slot(&managed, true).unwrap(), + Some(slot) + ); + for fresh in [false, true] { + let mut partial = managed.clone(); + partial["HostConfig"]["Mounts"] + .as_array_mut() + .unwrap() + .remove(1); + assert!(interactive_environment_slot(&partial, fresh).is_err()); + let mut writable = managed.clone(); + writable["HostConfig"]["Mounts"][1]["ReadOnly"] = json!(false); + assert!(interactive_environment_slot(&writable, fresh).is_err()); + } + } + #[test] fn mounted_allocation_selection_rejects_ambiguous_partial_or_escaping_mounts() { let slot = format!( "hack-env-lease-dddddddd-dddd-4ddd-8ddd-dddddddddddd-{}", From 9e7cea4f07abd2fa0f1444a10eb2d7b34a581f39 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:46:13 +0000 Subject: [PATCH 07/13] fix(native): preserve exec output after macOS peer closure --- packages/runtime-core/src/provider/engine.rs | 2 + .../src/provider/engine/exec_io.rs | 147 +++++++++++++++++ .../src/provider/engine/interactive_exec.rs | 57 ++++--- .../src/provider/engine/relay_exec.rs | 28 ++-- .../src/provider/interactive_handoff.rs | 18 +-- .../src/provider/interactive_terminal.rs | 14 +- tests/fixtures/native-interactive-frontend.py | 152 ++++++++++++++++-- tests/fixtures/native-interactive-terminal.py | 32 +++- 8 files changed, 386 insertions(+), 64 deletions(-) create mode 100644 packages/runtime-core/src/provider/engine/exec_io.rs diff --git a/packages/runtime-core/src/provider/engine.rs b/packages/runtime-core/src/provider/engine.rs index 271fc988d..38ad5b455 100644 --- a/packages/runtime-core/src/provider/engine.rs +++ b/packages/runtime-core/src/provider/engine.rs @@ -12,6 +12,8 @@ use std::io::Read; use std::path::Path; use std::time::{Duration, Instant}; +#[cfg(any(target_os = "macos", test))] +mod exec_io; #[cfg(any(target_os = "macos", test))] pub(in crate::provider) mod interactive_exec; #[cfg(any(target_os = "macos", test))] diff --git a/packages/runtime-core/src/provider/engine/exec_io.rs b/packages/runtime-core/src/provider/engine/exec_io.rs new file mode 100644 index 000000000..6ab1a030e --- /dev/null +++ b/packages/runtime-core/src/provider/engine/exec_io.rs @@ -0,0 +1,147 @@ +//! Deadline-bounded exec handshake I/O without changing shared descriptor flags. +//! macOS can reject SO_RCVTIMEO after peer closure even with bytes still queued. +//! Poll plus per-call nonblocking I/O preserves those bytes and never reads ahead. +use std::{ + io, + os::{fd::AsRawFd, unix::net::UnixStream}, + time::Instant, +}; + +fn wait(stream: &UnixStream, deadline: Instant, events: i16) -> io::Result<()> { + let remaining = deadline + .checked_duration_since(Instant::now()) + .filter(|value| !value.is_zero()) + .ok_or(io::ErrorKind::TimedOut)?; + let mut fd = libc::pollfd { + fd: stream.as_raw_fd(), + events, + revents: 0, + }; + // SAFETY: one initialized poll entry, with its stream retained throughout. + let result = unsafe { libc::poll(&mut fd, 1, remaining.as_millis().clamp(1, 100) as i32) }; + if Instant::now() >= deadline { + return Err(io::ErrorKind::TimedOut.into()); + } + if result < 0 { + return Err(io::Error::last_os_error()); + } + if result == 0 { + return Err(io::ErrorKind::WouldBlock.into()); + } + if fd.revents & libc::POLLNVAL != 0 { + return Err(io::ErrorKind::InvalidInput.into()); + } + // HUP/ERR may accompany buffered output. Let recv drain it or report EOF; + // send reports a closed peer without replaying any bytes already accepted. + Ok(()) +} + +pub(super) fn read(stream: &UnixStream, bytes: &mut [u8], deadline: Instant) -> io::Result { + wait(stream, deadline, libc::POLLIN)?; + // SAFETY: writable slice and live socket; MSG_DONTWAIT is per-call only. + let result = unsafe { + libc::recv( + stream.as_raw_fd(), + bytes.as_mut_ptr().cast(), + bytes.len(), + libc::MSG_DONTWAIT, + ) + }; + count(result) +} + +pub(super) fn write(stream: &UnixStream, bytes: &[u8], deadline: Instant) -> io::Result { + wait(stream, deadline, libc::POLLOUT)?; + // SAFETY: readable slice and live socket. Suppress SIGPIPE on peer closure. + let result = unsafe { + libc::send( + stream.as_raw_fd(), + bytes.as_ptr().cast(), + bytes.len(), + libc::MSG_DONTWAIT | libc::MSG_NOSIGNAL, + ) + }; + count(result) +} + +fn count(result: isize) -> io::Result { + if result < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(result as usize) + } +} + +pub(super) fn retry(error: &io::Error) -> bool { + matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{io::Write, time::Duration}; + + #[test] + fn closed_peer_keeps_buffered_bytes_and_descriptor_flags() { + let (stream, mut peer) = UnixStream::pair().unwrap(); + let flags = unsafe { libc::fcntl(stream.as_raw_fd(), libc::F_GETFL) }; + peer.write_all(b"ack\noutput").unwrap(); + drop(peer); + let deadline = Instant::now() + Duration::from_secs(1); + for expected in b"ack\noutput" { + let mut byte = [0]; + assert_eq!(read(&stream, &mut byte, deadline).unwrap(), 1); + assert_eq!(byte[0], *expected); + } + assert_eq!(read(&stream, &mut [0], deadline).unwrap(), 0); + assert_eq!( + unsafe { libc::fcntl(stream.as_raw_fd(), libc::F_GETFL) }, + flags + ); + } + + #[test] + fn blocked_write_respects_deadline_without_changing_flags() { + let (stream, _peer) = UnixStream::pair().unwrap(); + let flags = unsafe { libc::fcntl(stream.as_raw_fd(), libc::F_GETFL) }; + let deadline = Instant::now() + Duration::from_millis(50); + loop { + match write(&stream, &[0; 65536], deadline) { + Ok(n) => assert!(n > 0), + Err(error) if retry(&error) => continue, + Err(error) => { + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + break; + } + } + } + assert!(Instant::now() < deadline + Duration::from_secs(1)); + assert_eq!( + unsafe { libc::fcntl(stream.as_raw_fd(), libc::F_GETFL) }, + flags + ); + } + + #[test] + fn silent_peer_and_expired_write_respect_deadline() { + let (stream, _peer) = UnixStream::pair().unwrap(); + let deadline = Instant::now() + Duration::from_millis(20); + loop { + match read(&stream, &mut [0], deadline) { + Err(error) if retry(&error) => continue, + result => { + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::TimedOut); + break; + } + } + } + assert_eq!( + write(&stream, b"never sent", deadline).unwrap_err().kind(), + io::ErrorKind::TimedOut + ); + assert!(Instant::now() < deadline + Duration::from_secs(1)); + } +} diff --git a/packages/runtime-core/src/provider/engine/interactive_exec.rs b/packages/runtime-core/src/provider/engine/interactive_exec.rs index 0493567b8..995b97018 100644 --- a/packages/runtime-core/src/provider/engine/interactive_exec.rs +++ b/packages/runtime-core/src/provider/engine/interactive_exec.rs @@ -8,7 +8,6 @@ use crate::provider::interactive_session::{self, Binding, Receipt}; use base64::Engine as _; use std::{ collections::BTreeMap, - io::{Read, Write}, os::unix::net::UnixStream, time::{Duration, Instant}, }; @@ -164,19 +163,10 @@ pub(in crate::provider) fn deliver( for mut bytes in [header.as_bytes(), encoded.as_bytes()] { while !bytes.is_empty() { live()?; - stream - .set_write_timeout(Some(remaining(deadline)?.min(Duration::from_millis(100)))) - .map_err(|_| uncertain())?; - match stream.write(bytes) { + match super::exec_io::write(stream, bytes, deadline) { Ok(0) => return Err(uncertain()), Ok(n) => bytes = &bytes[n..], - Err(e) - if matches!( - e.kind(), - std::io::ErrorKind::WouldBlock - | std::io::ErrorKind::TimedOut - | std::io::ErrorKind::Interrupted - ) => {} + Err(e) if super::exec_io::retry(&e) => {} Err(_) => return Err(uncertain()), } } @@ -200,11 +190,8 @@ fn line( if bytes.len() == 64 { return Err(uncertain()); } - stream - .set_read_timeout(Some(remaining(deadline)?.min(Duration::from_millis(100)))) - .map_err(|_| uncertain())?; let mut byte = [0]; - match stream.read(&mut byte) { + match super::exec_io::read(stream, &mut byte, deadline) { Ok(0) => return Err(uncertain()), Ok(_) => { bytes.push(byte[0]); @@ -212,13 +199,7 @@ fn line( return Ok(bytes); } } - Err(e) - if matches!( - e.kind(), - std::io::ErrorKind::WouldBlock - | std::io::ErrorKind::TimedOut - | std::io::ErrorKind::Interrupted - ) => {} + Err(e) if super::exec_io::retry(&e) => {} Err(_) => return Err(uncertain()), } } @@ -306,6 +287,7 @@ fn wait_exit( #[cfg(test)] mod tests { use super::*; + use std::io::{Read, Write}; #[test] fn eof_can_precede_exit_publication_but_polling_is_bounded_and_fail_closed() { let mut calls = 0; @@ -383,6 +365,35 @@ mod tests { worker.join().unwrap(); } } + #[test] + fn cancellation_while_waiting_for_marker_sends_no_private_bytes() { + let (mut client, mut server) = UnixStream::pair().unwrap(); + let checks = std::cell::Cell::new(0); + let started = Instant::now(); + let error = deliver( + &mut client, + &BTreeMap::from([("TOKEN".into(), "synthetic-canary".into())]), + started + Duration::from_secs(2), + &|| { + checks.set(checks.get() + 1); + if checks.get() > 1 { + Err(CandidateError::new("cancelled", "Cancelled.")) + } else { + Ok(()) + } + }, + ) + .unwrap_err(); + assert_eq!(error.code, "cancelled"); + assert_eq!(checks.get(), 2); + assert!(started.elapsed() < Duration::from_secs(1)); + server.set_nonblocking(true).unwrap(); + assert_eq!( + server.read(&mut [0]).unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + } + #[test] fn old_or_malformed_launcher_receives_no_private_bytes() { for marker in [ diff --git a/packages/runtime-core/src/provider/engine/relay_exec.rs b/packages/runtime-core/src/provider/engine/relay_exec.rs index f78b3d03f..d7f11152f 100644 --- a/packages/runtime-core/src/provider/engine/relay_exec.rs +++ b/packages/runtime-core/src/provider/engine/relay_exec.rs @@ -10,7 +10,7 @@ use reqwest::Method; #[cfg(target_os = "macos")] use serde_json::json; use std::{ - io::{Read, Write}, + io::Read, os::unix::net::UnixStream, time::{Duration, Instant}, }; @@ -21,6 +21,7 @@ fn refused() -> CandidateError { "Private Docker exec transport failed; guest state may require reconciliation. No request was replayed.", ) } +#[cfg(target_os = "macos")] fn remaining(deadline: Instant) -> Result { deadline .checked_duration_since(Instant::now()) @@ -141,22 +142,26 @@ pub(super) fn start_mode( "POST /v1.53/exec/{id}/start HTTP/1.1\r\nHost: hack-local\r\nConnection: Upgrade\r\nUpgrade: tcp\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", body.len() ); - stream - .set_write_timeout(Some(remaining(deadline)?)) - .map_err(|_| refused())?; - stream - .write_all(request.as_bytes()) - .map_err(|_| refused())?; + let mut bytes = request.as_bytes(); + while !bytes.is_empty() { + match super::exec_io::write(stream, bytes, deadline) { + Ok(0) => return Err(refused()), + Ok(n) => bytes = &bytes[n..], + Err(error) if super::exec_io::retry(&error) => continue, + Err(_) => return Err(refused()), + } + } let mut header = Vec::new(); while !header.ends_with(b"\r\n\r\n") { if header.len() >= 8192 { return Err(refused()); } - stream - .set_read_timeout(Some(remaining(deadline)?)) - .map_err(|_| refused())?; let mut byte = [0]; - stream.read_exact(&mut byte).map_err(|_| refused())?; + match super::exec_io::read(stream, &mut byte, deadline) { + Ok(1) => {} + Err(error) if super::exec_io::retry(&error) => continue, + _ => return Err(refused()), + } header.push(byte[0]); } let text = std::str::from_utf8(&header).map_err(|_| refused())?; @@ -223,6 +228,7 @@ fn decode( mod tests { use super::*; use crate::provider::relay_auth::{Binding, Credential}; + use std::io::Write; fn frame(stream: u8, value: &[u8]) -> Vec { let mut bytes = vec![stream, 0, 0, 0]; bytes.extend_from_slice(&(value.len() as u32).to_be_bytes()); diff --git a/packages/runtime-core/src/provider/interactive_handoff.rs b/packages/runtime-core/src/provider/interactive_handoff.rs index 90ed83cea..7c7f47f1a 100644 --- a/packages/runtime-core/src/provider/interactive_handoff.rs +++ b/packages/runtime-core/src/provider/interactive_handoff.rs @@ -224,6 +224,9 @@ mod tests { fn handoff_preserves_early_output_and_drops_sender_copy() { let (sender, receiver) = UnixStream::pair().unwrap(); let (session, mut engine) = UnixStream::pair().unwrap(); + engine + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); let deadline = Instant::now() + Duration::from_secs(2); engine.write_all(b"first output with upgrade").unwrap(); send(&sender, Some(session), deadline).unwrap(); @@ -236,9 +239,6 @@ mod tests { attached.read_exact(&mut early).unwrap(); assert_eq!(&early, b"first output with upgrade"); drop(attached); - engine - .set_read_timeout(Some(Duration::from_secs(2))) - .unwrap(); assert_eq!(engine.read(&mut [0]).unwrap(), 0); } @@ -250,13 +250,13 @@ mod tests { assert!(receive(&receiver, deadline).unwrap().is_none()); drop(receiver); let (session, mut engine) = UnixStream::pair().unwrap(); + engine + .set_read_timeout(Some(Duration::from_secs(1))) + .unwrap(); assert_eq!( send(&sender, Some(session), deadline).unwrap_err().code, "session_cleanup_uncertain" ); - engine - .set_read_timeout(Some(Duration::from_secs(1))) - .unwrap(); assert_eq!(engine.read(&mut [0]).unwrap(), 0); } @@ -273,6 +273,9 @@ mod tests { for count in [2usize, 200] { let (sender, receiver) = UnixStream::pair().unwrap(); let (session, mut engine) = UnixStream::pair().unwrap(); + engine + .set_read_timeout(Some(Duration::from_secs(1))) + .unwrap(); let mut tag = 1u8; let mut iov = libc::iovec { iov_base: (&mut tag as *mut u8).cast(), @@ -309,9 +312,6 @@ mod tests { .code, "session_cleanup_uncertain" ); - engine - .set_read_timeout(Some(Duration::from_secs(1))) - .unwrap(); assert_eq!( engine.read(&mut [0]).unwrap(), 0, diff --git a/packages/runtime-core/src/provider/interactive_terminal.rs b/packages/runtime-core/src/provider/interactive_terminal.rs index 854ff66c7..9b53466f4 100644 --- a/packages/runtime-core/src/provider/interactive_terminal.rs +++ b/packages/runtime-core/src/provider/interactive_terminal.rs @@ -501,12 +501,21 @@ mod tests { .unwrap() .parse() .unwrap(); - assert!(control > 2 && engine > 2 && control != engine); + let finish: i32 = std::env::var("HACK_INTERACTIVE_FINISH") + .unwrap() + .parse() + .unwrap(); + assert!(control > 2 && engine > 2 && finish > 2); + assert!(control != engine && control != finish && engine != finish); // SAFETY: the harness transferred these distinct inherited descriptors // to this dedicated process; neither has another Rust owner. let control = unsafe { OwnedFd::from_raw_fd(control) }; let mut report = File::from(control.try_clone().unwrap()); let mut engine = unsafe { UnixStream::from_raw_fd(engine) }; + let mut finish = unsafe { UnixStream::from_raw_fd(finish) }; + finish + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); let mut terminal = Terminal::open().unwrap(); assert_eq!(terminal.size().unwrap(), (24, 80)); let (leash, payload) = Leash::receive(control).unwrap(); @@ -526,6 +535,9 @@ mod tests { } terminal.restore().unwrap(); report.write_all(b"D").unwrap(); + let mut ack = [0]; + finish.read_exact(&mut ack).unwrap(); + assert_eq!(&ack, b"F"); } #[test] diff --git a/tests/fixtures/native-interactive-frontend.py b/tests/fixtures/native-interactive-frontend.py index 375ea957d..ad6bce652 100644 --- a/tests/fixtures/native-interactive-frontend.py +++ b/tests/fixtures/native-interactive-frontend.py @@ -5,11 +5,81 @@ import pty import select import signal +import socket import subprocess import sys import termios import time + +def hold_session(): + """Stand in for the shell, keeping Darwin's controlling tty alive.""" + control = socket.socket(fileno=int(sys.argv[2])) + gate_read, gate_write = os.pipe() + child = os.fork() + if child == 0: + control.close() + os.close(gate_write) + os.setpgid(0, 0) + assert os.read(gate_read, 1) == b"G" + os.close(gate_read) + try: + os.execvpe(sys.argv[3], sys.argv[3:], os.environ) + except BaseException: + os._exit(127) + os.close(gate_read) + os.setpgid(child, child) + previous = signal.signal(signal.SIGTTOU, signal.SIG_IGN) + os.tcsetpgrp(0, child) + signal.signal(signal.SIGTTOU, previous) + control.sendall(b"P" + child.to_bytes(4, "big")) + os.write(gate_write, b"G") + os.close(gate_write) + _, status = os.waitpid(child, 0) + code = os.waitstatus_to_exitcode(status) + control.sendall(b"E" + code.to_bytes(4, "big", signed=True)) + control.settimeout(15) + assert control.recv(1) == b"F" + control.close() + + +if sys.argv[1] == "--hold-session": + hold_session() + sys.exit(0) + + +def drain(master, output, timeout): + if select.select([master], [], [], timeout)[0]: + output.extend(os.read(master, 8192)) + assert len(output) <= 1024 * 1024, "frontend output exceeded fixture bound" + + +def wait_draining(process, master, output, timeout, mode): + # A PTY consumer must keep reading while its session exits. Darwin can wait + # for tty output to drain during close, even after the child is signalled. + deadline = time.monotonic() + timeout + while process.poll() is None: + assert time.monotonic() < deadline, f"frontend exit deadline ({mode})" + drain(master, output, .05) + return process.returncode + + +def record(control, master, output, expected, timeout, mode): + deadline = time.monotonic() + timeout + data = bytearray() + while len(data) < 5: + assert time.monotonic() < deadline, f"session {expected!r} deadline ({mode})" + ready, _, _ = select.select([control, master], [], [], .05) + if master in ready: + drain(master, output, 0) + if control in ready: + part = control.recv(5 - len(data)) + assert part, "session holder closed before reporting" + data.extend(part) + assert data[:1] == expected + return int.from_bytes(data[1:], "big", signed=expected == b"E") + + root = sys.argv[1] protected_keys = json.loads(sys.argv[2]) command = sys.argv[3:] @@ -25,11 +95,20 @@ def session(): os.setsid() fcntl.ioctl(slave, termios.TIOCSCTTY, 0) - process = subprocess.Popen(command + [root, mode], cwd=root, stdin=slave, - stdout=slave, stderr=slave, preexec_fn=session) + control, holder_control = socket.socketpair() + process = subprocess.Popen( + [sys.executable, os.path.abspath(__file__), "--hold-session", + str(holder_control.fileno()), *command, root, mode], + cwd=root, stdin=slave, stdout=slave, stderr=slave, preexec_fn=session, + pass_fds=(holder_control.fileno(),), + ) + holder_control.close() + frontend = None + code = None reader = None output = bytearray() try: + frontend = record(control, master, output, b"P", 10, mode) deadline = time.monotonic() + 15 while b"child-ready\n" not in output: assert time.monotonic() < deadline, repr(output) @@ -41,10 +120,10 @@ def session(): if mode == "child-kill": os.kill(reader, signal.SIGKILL) elif mode == "frontend-kill": - process.kill() + os.kill(frontend, signal.SIGKILL) elif mode == "cancel": - process.terminate() - code = process.wait(timeout=10) + os.kill(frontend, signal.SIGTERM) + code = record(control, master, output, b"E", 10, mode) if mode == "normal": assert code == 7, code else: @@ -53,8 +132,26 @@ def session(): until = time.monotonic() + 5 while not os.path.exists(os.path.join(root, "reader.closed")): assert time.monotonic() < until, "frontend death did not close fd3" - time.sleep(.01) - assert termios.tcgetattr(slave) == original, "terminal left raw" + drain(master, output, .01) + before_read = termios.tcgetattr(slave) + # As in the native terminal fixture, let Darwin process PENDIN before + # comparing every termios field while the shell's session stays alive. + os.set_blocking(slave, False) + try: + pending = os.read(slave, 1) + except BlockingIOError: + pending = None + finally: + os.set_blocking(slave, True) + assert pending is None, f"unexpected pending terminal input ({mode})" + actual = termios.tcgetattr(slave) + names = ("iflag", "oflag", "cflag", "lflag", "ispeed", "ospeed", "cc") + differences = [names[i] for i in range(7) if actual[i] != original[i]] + assert actual == original, ( + f"terminal left raw ({mode}): {differences}; " + f"lflag xor before read={original[3] ^ before_read[3]:#x}, " + f"after read={original[3] ^ actual[3]:#x}" + ) while select.select([master], [], [], 0)[0]: output.extend(os.read(master, 8192)) assert b"synthetic-private-envelope-canary" not in output @@ -63,15 +160,36 @@ def session(): assert b"session_cleanup_uncertain: session 00000000-0000-4000-8000-000000000001" in output, repr(output) assert b"hack down --path" in output and b"--branch 'topic'" in output finally: - if process.poll() is None: - process.kill() - process.wait(timeout=5) - if reader is not None: + body_failed = sys.exc_info()[0] is not None + cleanup_error = None + try: + for pid in (reader, frontend if code is None else None): + if pid is not None: + try: + if os.getsid(pid) == process.pid: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + if frontend is not None and code is None: + record(control, master, output, b"E", 5, mode) + control.sendall(b"F") + assert wait_draining(process, master, output, 5, mode) == 0 + except Exception as error: + cleanup_error = error + finally: + control.close() try: - if os.getsid(reader) == process.pid: - os.kill(reader, signal.SIGKILL) - except ProcessLookupError: - pass - os.close(master) - os.close(slave) + if process.poll() is None: + process.kill() + wait_draining(process, master, output, 5, mode) + except Exception as error: + cleanup_error = cleanup_error or error + finally: + os.close(master) + os.close(slave) + if cleanup_error is not None: + if not body_failed: + raise cleanup_error + print(f"session cleanup also failed ({mode}): {type(cleanup_error).__name__}", + file=sys.stderr) print("Frontend PTY: four cases passed") diff --git a/tests/fixtures/native-interactive-terminal.py b/tests/fixtures/native-interactive-terminal.py index c10a7a872..9296dc52b 100644 --- a/tests/fixtures/native-interactive-terminal.py +++ b/tests/fixtures/native-interactive-terminal.py @@ -19,9 +19,11 @@ def run(mode): original = termios.tcgetattr(slave) control, child_control = socket.socketpair() engine, child_engine = socket.socketpair() + finish, child_finish = socket.socketpair() env = {"PATH": "/usr/bin:/bin", "HACK_INTERACTIVE_CHILD": mode, "HACK_INTERACTIVE_CONTROL": str(child_control.fileno()), - "HACK_INTERACTIVE_ENGINE": str(child_engine.fileno())} + "HACK_INTERACTIVE_ENGINE": str(child_engine.fileno()), + "HACK_INTERACTIVE_FINISH": str(child_finish.fileno())} def session(): os.setsid() @@ -31,10 +33,12 @@ def session(): [sys.argv[1], "--exact", "provider::interactive_terminal::tests::child_session", "--nocapture", "--test-threads=1"], stdin=slave, stdout=slave, stderr=slave, env=env, - pass_fds=(child_control.fileno(), child_engine.fileno(), slave), preexec_fn=session, + pass_fds=(child_control.fileno(), child_engine.fileno(), child_finish.fileno(), slave), + preexec_fn=session, ) child_control.close() child_engine.close() + child_finish.close() control.sendall(bytes(4)) try: deadline = time.monotonic() + 10 @@ -79,7 +83,28 @@ def session(): else: raise AssertionError("unknown scenario") assert control.recv(1) == b"D", "child did not finish and restore" - assert termios.tcgetattr(slave) == original, "terminal attributes not restored" + before_read = termios.tcgetattr(slave) + # Darwin sets PENDIN when TIOCSETA restores ICANON. The next tty read + # processes it, as the shell would. Keep full equality after that read; + # don't mask flags or flush user input to satisfy the assertion. + os.set_blocking(slave, False) + try: + pending = os.read(slave, 1) + except BlockingIOError: + pending = None + finally: + os.set_blocking(slave, True) + assert pending is None, f"unexpected pending input after restore ({mode})" + actual = termios.tcgetattr(slave) + names = ("iflag", "oflag", "cflag", "lflag", "ispeed", "ospeed", "cc") + differences = [names[i] for i in range(7) if actual[i] != original[i]] + assert actual == original, ( + f"terminal attributes not restored ({mode}): {differences}; " + f"lflag xor before read={original[3] ^ before_read[3]:#x}, " + f"after read={original[3] ^ actual[3]:#x}" + ) + # Keep the session leader alive until the tty read and comparison finish. + finish.sendall(b"F") assert process.wait(timeout=5) == 0 finally: if process.poll() is None: @@ -87,6 +112,7 @@ def session(): process.wait(timeout=5) control.close() engine.close() + finish.close() os.close(master) os.close(slave) From 8d16c9dc4c8fbb9d7745323c4a2582d0731861e8 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 16:40:51 +0000 Subject: [PATCH 08/13] fix(native): bound descriptor parsing to copied control bytes --- docs/reference/native-authored-commands.md | 5 + .../src/provider/interactive_handoff.rs | 230 +++++++++++++++--- .../tla/interactive-session/COVERAGE.md | 9 + 3 files changed, 209 insertions(+), 35 deletions(-) diff --git a/docs/reference/native-authored-commands.md b/docs/reference/native-authored-commands.md index 800eb0c06..061b132a5 100644 --- a/docs/reference/native-authored-commands.md +++ b/docs/reference/native-authored-commands.md @@ -556,6 +556,11 @@ the UUID and an exact project/branch `hack down` command. Only same-incarnation container absence or a proven stop of the bound VM boot retires it. A new boot alone is not proof. Receipt bookkeeping failures do not block container cleanup. +A malformed or truncated descriptor handoff also retains the receipt and refuses +without replay. The receiver closes only descriptors present in the copied reply. +On macOS, the kernel may install additional descriptors whose numbers were omitted +by truncation; the short-lived native child releases them when it exits. + ![Interactive session states](../diagrams/interactive-session.svg) The [diagram source](../diagrams/interactive-session.mmd) and diff --git a/packages/runtime-core/src/provider/interactive_handoff.rs b/packages/runtime-core/src/provider/interactive_handoff.rs index 7c7f47f1a..26a5f3850 100644 --- a/packages/runtime-core/src/provider/interactive_handoff.rs +++ b/packages/runtime-core/src/provider/interactive_handoff.rs @@ -102,8 +102,73 @@ pub(super) fn send( } } +/// Decode only complete descriptor integers inside the copied control bytes. +/// Darwin copyout_control truncates the bytes but preserves the original +/// cmsg_len; trusting that length would adopt unrelated stack integers as fds. +/// This parser creates no descriptor ownership, so malformed lengths can be +/// tested without closing any process resources. +fn copied_rights(bytes: &[u8], copied: usize) -> (Vec, bool) { + let mut invalid = copied > bytes.len(); + let bytes = &bytes[..copied.min(bytes.len())]; + let base = unsafe { libc::CMSG_LEN(0) } as usize; + let mut offset = 0; + let mut descriptors = Vec::new(); + while offset < bytes.len() { + if bytes.len() - offset < size_of::() { + invalid = true; + break; + } + // SAFETY: a whole cmsghdr fits in this initialized byte slice; its + // fields are integers, and read_unaligned accepts arbitrary alignment. + let header = unsafe { + bytes + .as_ptr() + .add(offset) + .cast::() + .read_unaligned() + }; + // cmsg_len is usize on Linux and u32 on Darwin. + #[allow(clippy::unnecessary_cast)] + let length = header.cmsg_len as usize; + if length < base || bytes.len() - offset < base { + invalid = true; + break; + } + let available = length.min(bytes.len() - offset); + invalid |= length > available; + if header.cmsg_level == libc::SOL_SOCKET && header.cmsg_type == libc::SCM_RIGHTS { + invalid |= (length - base) % size_of::() != 0 + || (available - base) % size_of::() != 0; + for chunk in bytes[offset + base..offset + available].chunks_exact(size_of::()) { + let fd = i32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + if fd < 0 || descriptors.contains(&fd) { + invalid = true; + } else { + descriptors.push(fd); + } + } + } else { + invalid = true; + } + if length > available { + break; + } + // Header and payload are bounded by the small receive buffer. Advance + // by the platform's padding, never by an unbounded claimed length. + let space = unsafe { libc::CMSG_SPACE((length - base) as u32) } as usize; + if space >= bytes.len() - offset { + break; + } + offset += space; + } + (descriptors, invalid) +} + /// Received descriptors are owned immediately, including on truncated/invalid /// messages. No descriptor is exposed until its count, type and CLOEXEC are valid. +/// Darwin may install additional rights that it does not copy out on truncation. +/// Their numbers are unknowable: refuse without guessing or replay, and let the +/// short-lived receiving CLI process release them when it exits. pub(super) fn receive( control: &UnixStream, deadline: Instant, @@ -140,36 +205,18 @@ pub(super) fn receive( if count < 0 { return Err(refused()); } - let mut descriptors = Vec::new(); - let mut invalid = false; - // SAFETY: the kernel populated these headers within msg_controllen. - // Every SCM_RIGHTS integer transfers ownership once, even on refusal. - unsafe { - let mut header = libc::CMSG_FIRSTHDR(&message); - while !header.is_null() { - let length = (*header).cmsg_len as usize; - let base = libc::CMSG_LEN(0) as usize; - if length < base { - invalid = true; - break; - } - if (*header).cmsg_level == libc::SOL_SOCKET - && (*header).cmsg_type == libc::SCM_RIGHTS - { - invalid |= (length - base) % size_of::() != 0; - for index in 0..(length - base) / size_of::() { - let fd = libc::CMSG_DATA(header) - .cast::() - .add(index) - .read_unaligned(); - descriptors.push(OwnedFd::from_raw_fd(fd)); - } - } else { - invalid = true; - } - header = libc::CMSG_NXTHDR(&message, header); - } - } + // SAFETY: ancillary is initialized storage of this exact byte size; + // the parser borrows it and never reads beyond the returned length. + let bytes = unsafe { + std::slice::from_raw_parts(ancillary.as_ptr().cast::(), size_of_val(&ancillary)) + }; + let (raw, invalid) = copied_rights(bytes, message.msg_controllen as usize); + // SAFETY: only complete SCM_RIGHTS integers copied by this recvmsg are + // returned, each once. Each such descriptor transfers ownership to us. + let mut descriptors: Vec = raw + .into_iter() + .map(|fd| unsafe { OwnedFd::from_raw_fd(fd) }) + .collect(); for fd in &descriptors { // SAFETY: OwnedFd keeps this descriptor alive; macOS requires an // explicit CLOEXEC since it lacks MSG_CMSG_CLOEXEC. @@ -276,6 +323,20 @@ mod tests { engine .set_read_timeout(Some(Duration::from_secs(1))) .unwrap(); + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command + .args([ + "--exact", + "provider::interactive_handoff::tests::truncated_rights_child", + "--ignored", + "--nocapture", + ]) + .env("HACK_HANDOFF_TEST_CHILD", "1") + .stdin(std::process::Stdio::from(OwnedFd::from(receiver))) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + let mut child = command.spawn().unwrap(); + drop(command); let mut tag = 1u8; let mut iov = libc::iovec { iov_base: (&mut tag as *mut u8).cast(), @@ -306,11 +367,20 @@ mod tests { ); } drop(session); - assert_eq!( - receive(&receiver, Instant::now() + Duration::from_secs(1)) - .unwrap_err() - .code, - "session_cleanup_uncertain" + let deadline = Instant::now() + Duration::from_secs(10); + while child.try_wait().unwrap().is_none() { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("isolated handoff receiver exceeded its deadline"); + } + std::thread::sleep(Duration::from_millis(10)); + } + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) ); assert_eq!( engine.read(&mut [0]).unwrap(), @@ -319,6 +389,96 @@ mod tests { ); } } + #[test] + #[ignore = "isolated receiver launched by multiple_and_truncated_rights_close_every_received_copy"] + fn truncated_rights_child() { + if std::env::var("HACK_HANDOFF_TEST_CHILD").as_deref() != Ok("1") { + return; + } + // SAFETY: the parent installed the control socket as this process's + // stdin through Stdio. No other Rust object owns fd 0 in this child. + let receiver = unsafe { UnixStream::from_raw_fd(0) }; + let (mut sentinel, mut peer) = UnixStream::pair().unwrap(); + let protected = [0, 1, 2, sentinel.as_raw_fd(), peer.as_raw_fd()]; + let before = protected.map(|fd| unsafe { libc::fcntl(fd, libc::F_GETFD) }); + assert!(before.iter().all(|flags| *flags >= 0)); + assert_eq!( + receive(&receiver, Instant::now() + Duration::from_secs(2)) + .unwrap_err() + .code, + "session_cleanup_uncertain" + ); + let after = protected.map(|fd| unsafe { libc::fcntl(fd, libc::F_GETFD) }); + assert_eq!( + after, before, + "refusal must not close unrelated descriptors" + ); + sentinel.write_all(b"alive").unwrap(); + let mut value = [0; 5]; + peer.read_exact(&mut value).unwrap(); + assert_eq!(&value, b"alive"); + // macOS may have installed uncopied rights. This isolated process + // exits to close them; the parent requires EOF after that exit. + } + + fn control_bytes(fds: &[i32], claimed: usize) -> Vec { + let mut bytes = vec![0; 512]; + // SAFETY: storage fits this header and the supplied test descriptors; + // unaligned accesses do not assume Vec has cmsghdr alignment. + unsafe { + let mut header: libc::cmsghdr = zeroed(); + header.cmsg_level = libc::SOL_SOCKET; + header.cmsg_type = libc::SCM_RIGHTS; + header.cmsg_len = libc::CMSG_LEN((claimed * size_of::()) as u32) as _; + bytes + .as_mut_ptr() + .cast::() + .write_unaligned(header); + let base = libc::CMSG_LEN(0) as usize; + for (index, fd) in fds.iter().enumerate() { + bytes + .as_mut_ptr() + .add(base + index * size_of::()) + .cast::() + .write_unaligned(*fd); + } + } + bytes + } + + #[test] + fn claimed_control_length_cannot_adopt_uncopied_zero_padding() { + let bytes = control_bytes(&[41], 200); + let copied = unsafe { libc::CMSG_LEN(size_of::() as u32) } as usize; + assert_eq!(copied_rights(&bytes, copied), (vec![41], true)); + let mut shorter = bytes[..copied].to_vec(); + shorter.extend_from_slice(&[0, 0]); + assert_eq!(copied_rights(&shorter, shorter.len()), (vec![41], true)); + assert_eq!( + copied_rights(&bytes, size_of::() - 1), + (vec![], true) + ); + assert_eq!( + copied_rights(&bytes[..copied], usize::MAX), + (vec![41], true) + ); + } + + #[test] + fn complete_control_returns_each_received_integer_once() { + let bytes = control_bytes(&[41, 42], 2); + let copied = unsafe { libc::CMSG_LEN(2 * size_of::() as u32) } as usize; + assert_eq!(copied_rights(&bytes, copied), (vec![41, 42], false)); + assert_eq!( + copied_rights(&control_bytes(&[41, 41], 2), copied), + (vec![41], true) + ); + assert_eq!( + copied_rights(&control_bytes(&[-1, 42], 2), copied), + (vec![42], true) + ); + } + #[test] fn datagram_is_not_an_engine_stream() { let (sender, receiver) = UnixStream::pair().unwrap(); diff --git a/tests/models/tla/interactive-session/COVERAGE.md b/tests/models/tla/interactive-session/COVERAGE.md index cb82984d3..2ec2a2951 100644 --- a/tests/models/tla/interactive-session/COVERAGE.md +++ b/tests/models/tla/interactive-session/COVERAGE.md @@ -26,3 +26,12 @@ identity, real Engine observations and the VM absence proof are assumptions. The model excludes multiple sessions, physical descriptor transfer, terminal job control and scheduler fairness. There is no eventual-cleanup claim. A normal exec exit can leave descendants and therefore retains its receipt until exact cleanup. + +Descriptor truncation is covered separately by `interactive_handoff` tests. A pure +parser regression preserves Darwin's original ancillary header length while +limiting copied bytes; it rejects the message without reading padding as file +descriptors. The kernel regression receives two or 200 descriptors in an isolated +child and checks that refusal preserves standard streams and sentinel sockets. +Darwin can install rights whose numbers are omitted from a truncated reply; the +short-lived receiver exits to release those rights. The receipt remains uncertain +and no start is replayed. Linux execution does not qualify Darwin's kernel path. From 0c7d926ddf14617a22b6c14aed14ef7945a10dec Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 18:21:52 +0000 Subject: [PATCH 09/13] fix(native): bound interactive I/O and retire confirmed sessions --- .github/workflows/ci.yml | 4 + docs/diagrams/interactive-session.mmd | 4 +- docs/diagrams/interactive-session.svg | 4 +- docs/guides/native-candidate.md | 39 +- docs/reference/cli.md | 21 ++ docs/reference/native-authored-commands.md | 39 +- docs/reference/v5-model-coverage.md | 2 +- package.json | 1 + packages/runtime-core/src/error.rs | 11 +- .../runtime-core/src/interactive_exec_cli.rs | 79 +++- packages/runtime-core/src/main.rs | 12 +- .../src/provider/engine/exec_io.rs | 45 ++- .../src/provider/engine/interactive_exec.rs | 106 +++++- .../src/provider/graph/interactive_exec.rs | 25 +- .../src/provider/graph/native/foreground.rs | 16 +- .../src/provider/graph/native/runtime/exec.rs | 2 + .../src/provider/graph/service_exec.rs | 9 +- .../src/provider/interactive_session.rs | 356 +++++++++++++++--- .../src/provider/interactive_terminal.rs | 31 +- scripts/check-native-guest.sh | 13 + scripts/lib/tla-runtime-models.ts | 16 +- src/backends/native-interactive-exec.ts | 106 ++++-- src/cli/spec.ts | 2 + src/commands/exec-sessions.ts | 140 +++++++ tests/fixtures/native-interactive-frontend.py | 62 ++- tests/fixtures/native-interactive-frontend.ts | 12 +- tests/fixtures/native-interactive-runtime.py | 22 +- tests/fixtures/native-interactive-terminal.py | 85 ++++- .../tla/interactive-session/COVERAGE.md | 28 +- .../tla/interactive-session/Session.tla | 56 +-- .../interactive-session/eof-retirement.cfg | 3 +- .../interactive-session/failed-inspect.cfg | 7 + .../tla/interactive-session/negative.cfg | 3 +- .../tla/interactive-session/positive.cfg | 3 +- .../tla/interactive-session/wrong-boot.cfg | 3 +- tests/native-authored-project-exec.test.ts | 10 +- tests/native-exec-sessions.test.ts | 65 ++++ tests/native-interactive-exec.test.ts | 17 +- tests/native-project-exec.test.ts | 10 +- 39 files changed, 1232 insertions(+), 237 deletions(-) create mode 100644 scripts/check-native-guest.sh create mode 100644 src/commands/exec-sessions.ts create mode 100644 tests/models/tla/interactive-session/failed-inspect.cfg create mode 100644 tests/native-exec-sessions.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10fb979a1..9579d8a32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -421,6 +421,10 @@ jobs: (cd "$destination" && echo "$checksum $archive" | shasum -a 256 --check) tar -xf "$destination/$archive" -C "$destination" echo "$destination/zig-${platform}-0.15.2" >> "$GITHUB_PATH" + - name: Build and verify bundle guest + run: | + rustup target add aarch64-unknown-linux-musl + sh scripts/check-native-guest.sh - name: Test interactive guest launcher on a real Linux PTY if: runner.os == 'Linux' shell: bash diff --git a/docs/diagrams/interactive-session.mmd b/docs/diagrams/interactive-session.mmd index 781ea40a3..d51e57dd8 100644 --- a/docs/diagrams/interactive-session.mmd +++ b/docs/diagrams/interactive-session.mmd @@ -30,6 +30,8 @@ stateDiagram-v2 end note note right of retired INV RetiredWithProof - Same-incarnation container absence + ExecExitedValidated for exact exec + or same-incarnation container absence or proven bound VM boot stop + EOF or failed inspect alone is unknown end note diff --git a/docs/diagrams/interactive-session.svg b/docs/diagrams/interactive-session.svg index 8b48514c2..fa7e2f03c 100644 --- a/docs/diagrams/interactive-session.svg +++ b/docs/diagrams/interactive-session.svg @@ -1,4 +1,4 @@ -Session · admission and cleanup proofAdmitWithdraw [undispatched]CreateRecordStart [fresh]AttachEOFEOFCrashCrashCrashCrashCrashCrashRetire [exact proof]Retire [exact proof]newadmittedwithdrawncreatedstartedattachedendedrecordedabandonedretiredINV WriteAheadIntent and exec ID durable before StartINV NoReplayNo recovered admission capabilityINV RetiredWithProofSame-incarnation container absenceor proven bound VM boot stop \ No newline at end of file +]]>Session · admission and cleanup proofAdmitWithdraw [undispatched]CreateRecordStart [fresh]AttachEOFEOFCrashCrashCrashCrashCrashCrashRetire [exact proof]Retire [exact proof]newadmittedwithdrawncreatedstartedattachedendedrecordedabandonedretiredINV WriteAheadIntent and exec ID durable before StartINV NoReplayNo recovered admission capabilityINV RetiredWithProofExecExitedValidated for exact execor same-incarnation container absenceor proven bound VM boot stopEOF or failed inspect alone is unknown \ No newline at end of file diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index ad6543d47..00c1a0223 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -4089,7 +4089,7 @@ refuse before execution; restart with the matching candidate to adopt this path. Services without managed values use ordinary exec. The native live qualification of this fresh-delivery path remains separate from its unit and transport tests. -With terminal stdin and stdout, `hack exec SERVICE -- /bin/sh` selects interactive +With terminal stdin and stdout in the foreground, `hack exec SERVICE -- /bin/sh` selects interactive exec. It streams both guest output channels through the terminal, forwards resize and terminal control bytes, and releases the provider mutation lease after attach. `down` and `restart` can proceed while the session is open. Authored projects use @@ -4100,7 +4100,8 @@ and enters raw mode before announcing readiness. Hack then sends a bounded base6 private envelope, waits for the launcher's acknowledgement after terminal restoration, and only then forwards user input. The envelope cannot contain Docker detach-control bytes. Old launcher mounts refuse before any value is sent; restart -the service with the matching runtime to enable this mode. The launcher returns +the service with the matching runtime to enable this mode, or use +`HACK_NO_INTERACTIVE=1 hack exec SERVICE -- COMMAND` for finite exec. The launcher returns 125 if admission or exec fails, including an exec failure after acknowledgement. This does not isolate values from other processes running as the same container user, which already share access to the command's environment. @@ -4110,21 +4111,43 @@ not reuse mounted startup values. This requirement applies only to the selected service: a peer service with no managed values still uses ordinary interactive exec. Finite exec retains its existing behavior. -An interactive command's exit code does not prove its descendants are gone. Hack -retains a value-free session receipt and prints `session_cleanup_uncertain` with -its UUID and exact `hack down` remedy until the same container incarnation is -absent or its VM boot is proven stopped. EOF, detach, a dead exec process and a -changed boot alone are not cleanup proof. No uncertain command is replayed. +Normal stream closure plus a validated Engine exit returns the exit code and +silently retires the session receipt. Surviving background processes are the +user's responsibility, as with Docker. Lost connections or processes before a +known outcome, uncertain create/start dispatch and failed inspection retain a +value-free receipt and `session_cleanup_uncertain` with the attempted session ID. +No uncertain command is replayed. + +| stdin | stdout | Foreground group | `HACK_NO_INTERACTIVE` | Selection | +| --- | --- | --- | --- | --- | +| TTY | TTY | Yes | unset or not `1` | Interactive | +| TTY | TTY | No (`&` job) | any | Existing finite exec | +| TTY | redirected | any | any | Existing finite exec | +| pipe or file | any | any | any | Existing finite exec | +| any | any | any | `1` | Existing finite exec | + +`hack exec-sessions` lists current service names and run/session IDs without +taking a mutation lock or writing state. It works after project mappings are +retired because it selects the native home. `hack exec-sessions --reconcile` +clears only a validated known exec exit, exact same-boot container absence or +proven stopped-boot evidence; intents without an exec ID require one of the +latter two proofs. It reports the exact remaining count and IDs after its bounded +pass. Use `hack down` for an affected project if uncertainty remains. A new boot +alone is not proof. Ordinary successful exec does not consume the 1024 retained +session limit. ### Interactive exec acceptance On the built current-branch runtime and CLI, exercise authored and adopted services: - A shell with foreground/background jobs; Ctrl-C, Ctrl-Z, `fg` and resize. +- A 2000-line burst and exit 7, followed by finite exec, second and third terminal + sessions, `down` and `up -d`; ordinary exits leave no retained receipts. - `hack exec SERVICE -- stty size` reports the host terminal's dimensions on its first output, before any subsequent window resize. - Ctrl-P Ctrl-Q disconnect, retaining its UUID and cleanup remedy. -- A background process that survives shell exit; receipt remains until `down`. +- A background process that survives shell exit; a validated shell exit is silent + and retires its receipt under the Docker-compatible policy. - `down` and restart from another terminal while the session is active; the owner stays responsive and the old session never attaches to the new generation. - Native child death and frontend death, including an idle terminal; attributes diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 818c116ab..8d78261a3 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1536,6 +1536,27 @@ hack projects prune [options] ## Diagnostics +## `hack exec-sessions` + +List retained native interactive exec sessions in this native home + +### Usage + +```bash +hack exec-sessions [options] +``` + +### Options + +| Option | Description | +| --- | --- | +| `--reconcile` | Retire only sessions with proven exit, container absence or a stopped boot | +| `--json` | Print value-free session evidence as JSON | +| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | +| `--help, -h` | Show help | +| `--version, -v` | Show version | + + ## `hack log-pipe` Read log lines from stdin and pretty-print them (best-effort JSON parsing) diff --git a/docs/reference/native-authored-commands.md b/docs/reference/native-authored-commands.md index 061b132a5..c4ca06d24 100644 --- a/docs/reference/native-authored-commands.md +++ b/docs/reference/native-authored-commands.md @@ -522,7 +522,7 @@ installed native exec or live application behavior. ## Interactive service exec -When both stdin and stdout are terminals, `hack exec SERVICE -- /bin/sh` opens a +When stdin and stdout are terminals and Hack is in the foreground, `hack exec SERVICE -- /bin/sh` opens a TTY session. `HACK_NO_INTERACTIVE=1` selects the finite path instead. The shell is explicit: Hack never inserts one. Authored commands inherit the service's startup environment. The authenticated owner checks the same service and generation, @@ -530,11 +530,19 @@ creates and starts the exec, then hands its connected Engine stream to the nativ terminal child. The owner remains available for `down` and `restart`; no mutation lease is held during the session. -The native home holds at most 1024 retained sessions across all projects. -`session_receipt_limit` refuses further admission without discarding evidence; -run `hack down` for a project with retained sessions to release its receipts. -This includes normally exited commands whose background descendants remain -unproven. The command's known exit status is still preserved. +The native home holds at most 1024 **uncertain** sessions across all projects. +A normally closed stream plus a validated Engine exit retires its receipt silently +and returns the command's exit code. Successful commands do not accumulate receipts. +If retirement bookkeeping fails, the validated exit code is preserved and the +remaining receipt is reported for reconciliation. +Use `hack exec-sessions` for a read-only, lock-free listing of service names, run IDs +and session IDs; it retries a changing directory snapshot at most three times, +then names `session_store_changed` instead of claiming corruption. It creates no state and prints no command or environment values. +Use `hack exec-sessions --reconcile` to clear only proven exits, exact container +absence or stopped-boot evidence. It prints the current count and IDs remaining, +including when its bounded reconciliation cannot finish. An intent without an +exec ID needs exact container absence or stopped-boot proof. `hack down` for the +affected project is the fallback. A full home names both listing and reconciliation. The initial terminal size is supplied before the guest command starts. If the host reports an unknown (zero) dimension, Hack uses 24 rows or 80 columns @@ -549,12 +557,19 @@ child failure. If restoration fails, Hack names `stty sane` as the repair. Before create, Hack durably records a value-free session UUID bound to the exact owner, run, boot, container and start generation; it records the exec ID before start. A known refusal before create dispatch withdraws only its own intent; -uncertain dispatch retains it. Neither operation is replayed. An exec exit code is returned when -known, but EOF or process exit cannot retire the session receipt because background -descendants may remain. Retained evidence produces `session_cleanup_uncertain`, -the UUID and an exact project/branch `hack down` command. Only same-incarnation -container absence or a proven stop of the bound VM boot retires it. A new boot -alone is not proof. Receipt bookkeeping failures do not block container cleanup. +uncertain dispatch retains it. Neither operation is replayed. Normal stream +closure and a validated exit for the exact exec, container generation, owner and +boot retire the receipt. EOF alone, a failed inspect, frontend/native process +loss and uncertain create/start outcomes retain evidence. The diagnostic queries +only the attempted session UUID; pre-intent refusals do not list old sessions. +Late errors preserve that UUID, and reports use the current evidence after +reconciliation. A changed boot alone is never proof. Receipt bookkeeping failures +do not block container cleanup; cleanup reports any evidence remaining in the +native home. + +Terminal restoration blocks SIGTTOU on the restoring thread so it can finish after +the shell reclaims the foreground. If the shell immediately starts another raw-mode +job, a late restoration can still race that job's termios change. A malformed or truncated descriptor handoff also retains the receipt and refuses without replay. The receiver closes only descriptors present in the copied reply. diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 39d63308a..4390215cc 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -36,7 +36,7 @@ must be attached to the eventual exact integration revision. | Property and critical states | Model coverage | Implementation/regression boundary | Remaining qualification | | --- | --- | --- | --- | -| Interactive exec write-ahead admission, crash, EOF, replacement boot and retirement | interactive-session (180 states); no-replay, EOF-retirement and wrong-boot negative controls | `interactive_session.rs` atomic intent/exec records, exact stopped-boot proof, known-undispatched withdrawal and lock-free observation/revalidation tests; Engine effect fences; authored owner start and SCM_RIGHTS handoff | One session, two boot identities, two possible starts. Atomic durable publication and exact identity/absence proofs are assumptions; real fsync/power loss, real owner/Engine handoff and VM loss need Mac acceptance. No descendant cleanup inferred from exec exit. | +| Interactive exec write-ahead admission, crash, EOF, replacement boot and retirement | interactive-session (459 states); no-replay, EOF-retirement, failed-inspect and wrong-boot negative controls | `interactive_session.rs` atomic intent/exec records, exact stopped-boot proof, known-undispatched withdrawal and lock-free observation/revalidation tests; Engine effect fences; authored owner start and SCM_RIGHTS handoff | One session, two boot identities, two possible starts. Atomic durable publication and exact identity/absence proofs are assumptions; real fsync/power loss, real owner/Engine handoff and VM loss need Mac acceptance. Validated exact exec exit permits retirement; it makes no descendant cleanup claim. | | Private interactive envelope, echo state and bytes still in flight on failure | interactive-envelope (15 states); restore-on-error negative control | HKEE2 launcher and host gate, Linux PTY seven-case matrix, bounded marker/ACK and coalesced-output tests | Two abstract private bytes, one handshake. PTY fixtures prove local termios ordering; Docker detach scanning, real guest wiring, same-user access and VM effects remain separate. Source/compiled Bun PTY and native-process fixtures cover lifetime/terminal boundaries, not model liveness. | | Guest control ready before exposed bridge sockets bind; identity changes or the startup deadline expires | No new TLA+ model; deterministic clock schedules both readiness orders against real Unix socket nodes | `provider/bridge.rs` waits only in fresh `finish_boot`, before adoption, and keeps running audits immediate. `bridge_startup_tests.rs` covers two slots, late creation and mode tightening, permanent absence/unsafe modes, type/UID/link refusals, provider loss before/after observation and late success rejection. No wait mutates sockets or grants cleanup authority; the existing verified-provider stop handles failure. `lifecycle/boot_sequence_tests.rs` drives the real waiter through the production fresh-boot sequence and rejects audit/adoption on timeout, unsafe sockets or provider loss. `native-runtime-client.test.ts` checks the fixed runtime recover command and identical-root hint reach the frontend | Two sockets, finite scheduled events, injected provider verification and monotonic clock. Kernel process identity, provider scheduling, disk durability and successful recovery remain host boundaries. Require ten consecutive real-VM Mac Ctrl-C scenarios with pool stop/start; portable tests are not that evidence. | | CI cancellation must not lose merged-change coverage; only exact-head full evidence may qualify a prerelease | No maintained GitHub scheduler model. Finite fixture interleavings cover predecessor success/failure/cancellation, two competing full/scoped runs and two rerun attempts | `ci.yml` serializes next pushes in one non-cancelling group (GitHub cancels only the replaced pending run), cancels stale PR runs, and requires a successful before run for selection. `selectPrereleaseCiRun` reads current attempts; `requireBranchChecks` binds suite and job check IDs. `ci-trusted-policy`, `prerelease-plan` and `release-prepare-gate` regressions reject cancelled predecessors, green subsets, stale checks, skipped hosts and changed attempts | API snapshots and GitHub group/event semantics are assumptions; races after the last read remain possible. Publication rechecks the frozen protected head and full evidence but does not transactionally lock GitHub. Hosted dispatch/cancellation checks qualify wiring; no scheduler liveness or formal refinement claim. | diff --git a/package.json b/package.json index e9bfb445d..f8c87c7c8 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "check:config-compiler": "cargo fmt --manifest-path packages/config-compiler/Cargo.toml --check && cargo clippy --locked --manifest-path packages/config-compiler/Cargo.toml --target-dir .hack-local/config-compiler-target --all-targets --jobs 2 -- -D warnings", "test:config-compiler": "cargo test --locked --manifest-path packages/config-compiler/Cargo.toml --target-dir .hack-local/config-compiler-target --jobs 2", "test:local": "cargo test --locked --manifest-path packages/runtime-core/Cargo.toml --target-dir .hack-local/target --jobs 2", + "check:native-guest": "sh scripts/check-native-guest.sh", "check:local": "cargo fmt --manifest-path packages/runtime-core/Cargo.toml --check && cargo clippy --locked --manifest-path packages/runtime-core/Cargo.toml --target-dir .hack-local/target --all-targets --jobs 2 -- -D warnings", "build:runtime-image": "bun run --cwd packages/cli build:runtime-image", "build:node-runtime-image": "bun run --cwd packages/cli build:node-runtime-image", diff --git a/packages/runtime-core/src/error.rs b/packages/runtime-core/src/error.rs index 01c23c86b..96f4e4bb5 100644 --- a/packages/runtime-core/src/error.rs +++ b/packages/runtime-core/src/error.rs @@ -205,7 +205,16 @@ pub struct CandidateError { impl CandidateError { pub fn with_session(mut self, session: &str) -> Self { - if crate::provider::environment_recovery::uuid(session) { + // This file is also compiled by the guest, which has no host provider. + if session.len() == 36 + && session.bytes().enumerate().all(|(i, c)| { + if [8, 13, 18, 23].contains(&i) { + c == b'-' + } else { + c.is_ascii_digit() || (b'a'..=b'f').contains(&c) + } + }) + { self.refusals.get_or_insert_default().session = Some(session.into()); } self diff --git a/packages/runtime-core/src/interactive_exec_cli.rs b/packages/runtime-core/src/interactive_exec_cli.rs index 393e663f8..0c3cb0c1a 100644 --- a/packages/runtime-core/src/interactive_exec_cli.rs +++ b/packages/runtime-core/src/interactive_exec_cli.rs @@ -35,6 +35,7 @@ pub fn command(candidate: &Candidate, args: &[&str]) -> Result Result Result Result<(), CandidateError> { - if run.len() != 32 - || !run - .bytes() - .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) - || service.is_empty() - || service.len() > 128 - || !service - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)) +pub fn sessions(candidate: &Candidate, args: &[&str]) -> Result<(), CandidateError> { + let mut values = BTreeMap::new(); + let mut reconcile = false; + let mut json = false; + let mut index = 0; + while index < args.len() { + let key = args[index]; + index += 1; + match key { + "--reconcile" if !reconcile => reconcile = true, + "--json" if !json => json = true, + "--run-id" | "--service" | "--session" => { + let value = *args.get(index).ok_or_else(invalid)?; + index += 1; + if values.insert(key, value).is_some() { + return Err(invalid()); + } + } + _ => return Err(invalid()), + } + } + let run = values.get("--run-id").copied(); + let service = values.get("--service").copied(); + let session = values.get("--session").copied(); + if !json + || run.is_some_and(|v| v.len() != 32 || !v.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))) + || service.is_some_and(|v| v.is_empty() || v.len() > 128 || !v.bytes().all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b))) + // Reconciliation is native-home wide; filters are presentation only. + || (reconcile && !values.is_empty()) { return Err(invalid()); } #[cfg(target_os = "macos")] { - let records = hack_runtime_core::provider::interactive_session::retained(candidate)?; - let sessions: Vec<_> = records + use hack_runtime_core::provider::interactive_session; + if session.is_some_and(|v| !interactive_session::valid_session(v)) { + return Err(invalid()); + } + let reconciled = if reconcile { + // Report current evidence even when inspection or the deadline refuses. + Some(interactive_session::reconcile_observed(candidate).is_ok()) + } else { + None + }; + let records: Vec<_> = interactive_session::retained(candidate)? .into_iter() - .filter(|r| r.binding.run == run && r.binding.service == service) - .map(|r| r.session) + .filter(|r| { + run.is_none_or(|v| r.binding.run == v) + && service.is_none_or(|v| r.binding.service == v) + && session.is_none_or(|v| r.session == v) + }) + .map(|r| { + serde_json::json!({ + "session":r.session, "run":r.binding.run, + "service":r.binding.service, "has_exec":r.exec.is_some() + }) + }) .collect(); - println!("{}", serde_json::json!({"version":1,"sessions":sessions})); + println!( + "{}", + serde_json::json!({"version":1,"sessions":records,"reconciled":reconciled}) + ); Ok(()) } #[cfg(not(target_os = "macos"))] { - let _ = (candidate, run, service); - Err(invalid()) + let _ = (candidate, session); + Err(CandidateError::new( + "interactive_exec_platform", + "Native interactive sessions require macOS.", + )) } } diff --git a/packages/runtime-core/src/main.rs b/packages/runtime-core/src/main.rs index 8119b0cf7..68cb6b2e2 100644 --- a/packages/runtime-core/src/main.rs +++ b/packages/runtime-core/src/main.rs @@ -373,16 +373,8 @@ fn run() -> Result<(), CandidateError> { let code = interactive_exec_cli::command(&discover_candidate(&requested)?, arguments)?; std::process::exit(code); } - [ - "graph", - "interactive-sessions", - "--run-id", - run, - "--service", - service, - "--json", - ] => { - interactive_exec_cli::sessions(&discover_candidate(&requested)?, run, service)?; + ["graph", "interactive-sessions", arguments @ ..] => { + interactive_exec_cli::sessions(&discover_candidate(&requested)?, arguments)?; } ["graph", arguments @ ..] => { let candidate = discover_candidate(&requested)?; diff --git a/packages/runtime-core/src/provider/engine/exec_io.rs b/packages/runtime-core/src/provider/engine/exec_io.rs index 6ab1a030e..ca609db1d 100644 --- a/packages/runtime-core/src/provider/engine/exec_io.rs +++ b/packages/runtime-core/src/provider/engine/exec_io.rs @@ -1,6 +1,6 @@ -//! Deadline-bounded exec handshake I/O without changing shared descriptor flags. +//! Deadline-bounded exec handshake I/O, restoring descriptor flags after writes. //! macOS can reject SO_RCVTIMEO after peer closure even with bytes still queued. -//! Poll plus per-call nonblocking I/O preserves those bytes and never reads ahead. +//! Poll plus nonblocking I/O preserves those bytes and never reads ahead. use std::{ io, os::{fd::AsRawFd, unix::net::UnixStream}, @@ -52,6 +52,16 @@ pub(super) fn read(stream: &UnixStream, bytes: &mut [u8], deadline: Instant) -> pub(super) fn write(stream: &UnixStream, bytes: &[u8], deadline: Instant) -> io::Result { wait(stream, deadline, libc::POLLOUT)?; + // Darwin can block a large send despite MSG_DONTWAIT. Handshake callers own + // this socket exclusively until attachment; use O_NONBLOCK for the syscall + // and restore its exact flags before returning or handing the stream off. + // SAFETY: the borrowed stream retains this descriptor throughout. + let flags = unsafe { libc::fcntl(stream.as_raw_fd(), libc::F_GETFL) }; + if flags < 0 + || unsafe { libc::fcntl(stream.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 + { + return Err(io::Error::last_os_error()); + } // SAFETY: readable slice and live socket. Suppress SIGPIPE on peer closure. let result = unsafe { libc::send( @@ -61,7 +71,12 @@ pub(super) fn write(stream: &UnixStream, bytes: &[u8], deadline: Instant) -> io: libc::MSG_DONTWAIT | libc::MSG_NOSIGNAL, ) }; - count(result) + let result = count(result); + // Capture send's errno before restoring flags, and refuse a failed restore. + if unsafe { libc::fcntl(stream.as_raw_fd(), libc::F_SETFL, flags) } < 0 { + return Err(io::Error::last_os_error()); + } + result } fn count(result: isize) -> io::Result { @@ -109,7 +124,7 @@ mod tests { let flags = unsafe { libc::fcntl(stream.as_raw_fd(), libc::F_GETFL) }; let deadline = Instant::now() + Duration::from_millis(50); loop { - match write(&stream, &[0; 65536], deadline) { + match write(&stream, &[0; 87384], deadline) { Ok(n) => assert!(n > 0), Err(error) if retry(&error) => continue, Err(error) => { @@ -125,6 +140,28 @@ mod tests { ); } + #[test] + fn failed_send_restores_blocking_and_nonblocking_flags() { + for nonblocking in [false, true] { + let (stream, peer) = UnixStream::pair().unwrap(); + stream.set_nonblocking(nonblocking).unwrap(); + let flags = unsafe { libc::fcntl(stream.as_raw_fd(), libc::F_GETFL) }; + drop(peer); + assert!( + write( + &stream, + &[0; 87384], + Instant::now() + Duration::from_secs(1) + ) + .is_err() + ); + assert_eq!( + unsafe { libc::fcntl(stream.as_raw_fd(), libc::F_GETFL) }, + flags + ); + } + } + #[test] fn silent_peer_and_expired_write_respect_deadline() { let (stream, _peer) = UnixStream::pair().unwrap(); diff --git a/packages/runtime-core/src/provider/engine/interactive_exec.rs b/packages/runtime-core/src/provider/engine/interactive_exec.rs index 995b97018..df838cabd 100644 --- a/packages/runtime-core/src/provider/engine/interactive_exec.rs +++ b/packages/runtime-core/src/provider/engine/interactive_exec.rs @@ -34,6 +34,7 @@ pub(in crate::provider) struct Attached { #[cfg(target_os = "macos")] pub(in crate::provider) struct Command<'a> { + pub session: &'a str, pub argv: &'a [String], pub workdir: Option<&'a str>, pub console_size: [u16; 2], @@ -55,7 +56,7 @@ impl Engine<'_> { fresh()?; self.guest.verify()?; self.guest.before_effect()?; - let intent = interactive_session::admit(candidate, binding.clone())?; + let intent = interactive_session::admit(candidate, binding.clone(), command.session)?; let session = intent.receipt().session.clone(); let mut intent = Some(intent); let mut dispatched = false; @@ -251,14 +252,25 @@ impl Observer<'_> { } Ok(exec) } + pub(in crate::provider) fn interactive_finished( + &self, + receipt: &Receipt, + deadline: Instant, + ) -> Result, CandidateError> { + let value = self.interactive_binding(receipt, deadline)?; + self.guest.verify()?; + exit_status(&value) + } pub(in crate::provider) fn interactive_exit( &self, receipt: &Receipt, ) -> Result { let deadline = Instant::now() + Duration::from_secs(2); - wait_exit(deadline, &mut || { + let exit = wait_exit(deadline, &mut || { self.interactive_binding(receipt, deadline) - }) + })?; + self.guest.verify()?; + Ok(exit) } } @@ -270,20 +282,25 @@ fn wait_exit( remaining(deadline)?; let value = inspect()?; remaining(deadline)?; - if value["Running"] == false { - return value["ExitCode"] - .as_i64() - .filter(|c| (0..=255).contains(c)) - .map(|c| c as i32) - .ok_or_else(uncertain); - } - if value["Running"] != true { - return Err(uncertain()); + if let Some(code) = exit_status(&value)? { + return Ok(code); } std::thread::sleep(remaining(deadline)?.min(Duration::from_millis(50))); } } +fn exit_status(value: &serde_json::Value) -> Result, CandidateError> { + match value["Running"].as_bool() { + Some(true) => Ok(None), + Some(false) => value["ExitCode"] + .as_i64() + .filter(|code| (0..=255).contains(code)) + .map(|code| Some(code as i32)) + .ok_or_else(uncertain), + None => Err(uncertain()), + } +} + #[cfg(test)] mod tests { use super::*; @@ -320,6 +337,71 @@ mod tests { ); assert_eq!(calls, 1); } + #[test] + fn exit_inspection_requires_a_known_status() { + for value in [ + serde_json::json!({}), + serde_json::json!({"Running":false}), + serde_json::json!({"Running":false,"ExitCode":-1}), + serde_json::json!({"Running":false,"ExitCode":256}), + serde_json::json!({"Running":false,"ExitCode":"0"}), + ] { + assert!(exit_status(&value).is_err()); + } + assert_eq!( + exit_status(&serde_json::json!({"Running":true})).unwrap(), + None + ); + assert_eq!( + exit_status(&serde_json::json!({"Running":false,"ExitCode":7})).unwrap(), + Some(7) + ); + } + #[test] + fn cancellation_during_large_envelope_write_is_bounded() { + let (mut client, mut server) = UnixStream::pair().unwrap(); + let capacity: libc::c_int = 4096; + use std::os::fd::AsRawFd; + // SAFETY: live socket, initialized integer and matching length. + assert_eq!( + unsafe { + libc::setsockopt( + client.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_SNDBUF, + (&capacity as *const libc::c_int).cast(), + std::mem::size_of_val(&capacity) as _, + ) + }, + 0 + ); + server.write_all(b"HKEE2 123\n").unwrap(); + let checks = std::cell::Cell::new(0); + let start = Instant::now(); + let flags = unsafe { libc::fcntl(client.as_raw_fd(), libc::F_GETFL) }; + let error = deliver( + &mut client, + &BTreeMap::from([("TOKEN".into(), "x".repeat(60000))]), + start + Duration::from_secs(3), + &|| { + checks.set(checks.get() + 1); + if start.elapsed() > Duration::from_millis(150) { + Err(CandidateError::new("cancelled", "Cancelled.")) + } else { + Ok(()) + } + }, + ) + .unwrap_err(); + assert_eq!(error.code, "cancelled"); + assert!(start.elapsed() < Duration::from_secs(1)); + assert!(checks.get() > 1); + assert_eq!( + unsafe { libc::fcntl(client.as_raw_fd(), libc::F_GETFL) }, + flags + ); + } + #[test] fn private_handshake_preserves_coalesced_command_output() { for ack in [b"HKEE2 OK\n".as_slice(), b"HKEE2 OK\r\n"] { diff --git a/packages/runtime-core/src/provider/graph/interactive_exec.rs b/packages/runtime-core/src/provider/graph/interactive_exec.rs index 8097c8af9..1ea55cb3f 100644 --- a/packages/runtime-core/src/provider/graph/interactive_exec.rs +++ b/packages/runtime-core/src/provider/graph/interactive_exec.rs @@ -3,6 +3,7 @@ use crate::{Candidate, CandidateError}; use std::os::fd::OwnedFd; pub struct Options<'a> { + pub session: &'a str, pub run: &'a str, pub service: &'a str, pub owner: &'a str, @@ -22,7 +23,8 @@ fn refused() -> CandidateError { } impl Options<'_> { pub fn validate(&self) -> Result<(), CandidateError> { - if !super::hex(self.run, 32) + if !crate::provider::environment_recovery::uuid(self.session) + || !super::hex(self.run, 32) || !super::hex(self.owner, 32) || !super::hex(self.plan, 64) || !super::hex(self.namespace, 64) @@ -158,13 +160,14 @@ fn native( timeout: Duration::from_secs(30), }, console_size, + options.session, managed.as_ref(), &|| leash.check(), ) }; let mut attached = admission?; let receipt = &attached.receipt; - let result = (|| { + let result: Result<(i32, bool), CandidateError> = (|| { leash.check()?; if let Some(managed) = &managed { interactive_exec::deliver( @@ -186,19 +189,21 @@ fn native( }) }, )?; - observer.interactive_exit(receipt) + let exit = observer.interactive_exit(receipt)?; + // A known command status survives bookkeeping failures. Retained + // evidence is reported separately and can be reconciled with this proof. + let retained = interactive_session::retire_completed(candidate, receipt).is_err(); + Ok((exit, retained)) })(); - terminal.restore()?; + terminal + .restore() + .map_err(|error| error.with_session(&receipt.session))?; drop(attached.stream); - let _ = interactive_session::reconcile_observed(candidate); - let remains = interactive_session::retained(candidate)? - .iter() - .any(|r| r.session == receipt.session); result - .map(|exit_code| Completion { + .map(|(exit_code, retained)| Completion { exit_code, session: receipt.session.clone(), - retained: remains, + retained, }) .map_err(|error| error.with_session(&receipt.session)) } diff --git a/packages/runtime-core/src/provider/graph/native/foreground.rs b/packages/runtime-core/src/provider/graph/native/foreground.rs index 8ccd70e00..666b3d066 100644 --- a/packages/runtime-core/src/provider/graph/native/foreground.rs +++ b/packages/runtime-core/src/provider/graph/native/foreground.rs @@ -73,12 +73,21 @@ struct Request { one_off: Option, #[serde(default, skip_serializing_if = "Option::is_none")] console_size: Option<[u16; 2]>, + #[serde(default, skip_serializing_if = "Option::is_none")] + session: Option, } impl Request { fn validate(&self, review: &native_input::Review) -> Result<(), CandidateError> { if self.version != 2 || self.run != review.scope().run || self.review != review.review_id() + || match self.session.as_deref() { + Some(id) => { + self.action != Action::InteractiveExec + || !crate::provider::environment_recovery::uuid(id) + } + None => self.action == Action::InteractiveExec, + } || match self.console_size { Some(size) => self.action != Action::InteractiveExec || size.contains(&0), None => self.action == Action::InteractiveExec, @@ -421,6 +430,7 @@ pub fn serve_with_storage_tool( &receipt, selected, request.console_size.ok_or_else(refused)?, + request.session.as_deref().ok_or_else(refused)?, &check, ) { Ok(session) => { @@ -628,6 +638,7 @@ pub(in crate::provider) fn interactive( review: review.review_id().into(), action: Action::InteractiveExec, console_size: Some(console_size), + session: Some(options.session.into()), logs: None, exec: Some(selected.clone()), one_off: None, @@ -646,7 +657,7 @@ pub(in crate::provider) fn interactive( None, )?; match reply.result { - Outcome::Interactive { session, .. } => { + Outcome::Interactive { session, .. } if session.session == options.session => { let result = (|| { journal::read_control(candidate, review)?.check_binding(&expected)?; pin.verify()?; @@ -655,7 +666,7 @@ pub(in crate::provider) fn interactive( })(); result.map_err(|_: CandidateError| CandidateError::new("session_cleanup_uncertain", "Interactive session handoff changed; the session was retained and no start was replayed.").with_session(&session.session)) } - Outcome::InteractiveRefused { session } => Err(CandidateError::new("session_cleanup_uncertain", "Interactive admission ended after its durable intent; the session was retained and no start was replayed.").with_session(&session)), + Outcome::InteractiveRefused { session } if session == options.session => Err(CandidateError::new("session_cleanup_uncertain", "Interactive admission ended after its durable intent; the session was retained and no start was replayed.").with_session(&session)), Outcome::Refused { code, .. } => Err(refused().with_cause_code(code)), _ => Err(refused()), } @@ -679,6 +690,7 @@ fn request_selected( review: review.review_id().into(), action, console_size: None, + session: None, logs: selected.clone(), exec: exec_selected.clone(), one_off: one_off_selected.clone(), diff --git a/packages/runtime-core/src/provider/graph/native/runtime/exec.rs b/packages/runtime-core/src/provider/graph/native/runtime/exec.rs index e3d9b2316..59e581140 100644 --- a/packages/runtime-core/src/provider/graph/native/runtime/exec.rs +++ b/packages/runtime-core/src/provider/graph/native/runtime/exec.rs @@ -179,6 +179,7 @@ pub(in crate::provider::graph::native) fn interactive( expected: &Receipt, selected: &ExecSelection, console_size: [u16; 2], + session: &str, guard: &dyn Fn() -> Result<(), CandidateError>, ) -> Result { use crate::provider::interactive_session::{Backend as SessionBackend, Binding}; @@ -241,6 +242,7 @@ pub(in crate::provider::graph::native) fn interactive( argv: &selected.argv, workdir: selected.workdir.as_deref(), console_size, + session, }, deadline, &fresh, diff --git a/packages/runtime-core/src/provider/graph/service_exec.rs b/packages/runtime-core/src/provider/graph/service_exec.rs index 227a194f6..dbe862179 100644 --- a/packages/runtime-core/src/provider/graph/service_exec.rs +++ b/packages/runtime-core/src/provider/graph/service_exec.rs @@ -72,6 +72,7 @@ pub(super) fn interactive( candidate: &Candidate, options: &ServiceExecOptions<'_>, console_size: [u16; 2], + session: &str, managed: Option<&crate::provider::managed_environment::Managed>, live: &dyn Fn() -> Result<(), CandidateError>, ) -> Result { @@ -120,7 +121,10 @@ pub(super) fn interactive( { return Err(refused()); } - require_current_launcher(&container, &super::launcher::current_source())?; + require_current_launcher(&container, &super::launcher::current_source()).map_err(|_| error( + "graph_service_exec_launcher", + "Interactive exec needs the current verified launcher. Use HACK_NO_INTERACTIVE=1 hack exec for finite exec, or restart the service with the matching candidate.", + ))?; let mut argv = vec![ "/run/hack-environment-launcher".into(), "--exec-environment-tty-v2".into(), @@ -176,6 +180,7 @@ pub(super) fn interactive( argv: &argv, workdir: options.workdir, console_size, + session, }, deadline, &fresh, @@ -354,7 +359,7 @@ fn interactive_environment_slot( if slot.is_some() && !fresh { return Err(error( "interactive_exec_environment_required", - "Interactive exec requires fresh selected-service environment delivery; no startup-environment fallback was attempted.", + "Interactive exec requires fresh selected-service environment delivery. Use HACK_NO_INTERACTIVE=1 hack exec for finite exec, or restart the service with the current candidate.", )); } Ok(slot) diff --git a/packages/runtime-core/src/provider/interactive_session.rs b/packages/runtime-core/src/provider/interactive_session.rs index 58895e2e9..9bb792ad9 100644 --- a/packages/runtime-core/src/provider/interactive_session.rs +++ b/packages/runtime-core/src/provider/interactive_session.rs @@ -1,19 +1,27 @@ //! Value-free write-ahead interactive intents. They authorize no replay. Even a -//! completed exec may have descendants, so only exact container absence retires -//! these records; transport EOF and a changed VM boot are never cleanup proof. +//! validated Engine exit retires a known exec; surviving descendants belong to +//! the user. EOF alone and a changed VM boot are never cleanup proof. use super::{engine::log_follow::valid_timestamp, environment_recovery, state}; use crate::{Candidate, CandidateError}; use serde::{Deserialize, Serialize}; use std::{ collections::BTreeMap, fs::{self, File}, - io::Read, os::unix::fs::MetadataExt, - path::PathBuf, + path::{Path, PathBuf}, time::{Duration, Instant}, }; const LIMIT: usize = 1024; +pub fn valid_session(value: &str) -> bool { + environment_recovery::uuid(value) +} +fn changed() -> CandidateError { + CandidateError::new( + "session_store_changed", + "Interactive session evidence changed during observation; retry hack exec-sessions. Nothing was cleared or replayed.", + ) +} fn refused() -> CandidateError { CandidateError::new( "session_receipt_refused", @@ -84,9 +92,9 @@ impl Receipt { struct Store { root: PathBuf, identity: (u64, u64), - lock: state::Lock, + lock: Option, } -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct StoppedBoot { owner: String, @@ -103,7 +111,50 @@ impl StoppedBoot { self.owner == binding.owner && self.boot == binding.boot } } +type Snapshot = (Vec, Vec); impl Store { + /// No creation, lease, pending-file pruning or other writes. + fn observe(candidate: &Candidate) -> Result, CandidateError> { + let root = candidate.state_root.join("run/exec-sessions"); + let metadata = match fs::symlink_metadata(&root) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(refused()), + Ok(metadata) => metadata, + }; + let store = Self { + root, + identity: (metadata.dev(), metadata.ino()), + lock: None, + }; + store.check()?; + Ok(Some(store)) + } + fn retire(&self, receipt: &Receipt) -> Result { + if self.lock.is_none() { + return Err(refused()); + } + let current = self.list()?; + match current.iter().find(|r| r.session == receipt.session) { + None => return Ok(false), // Another exact-proof reconciliation won. + Some(found) if found != receipt => return Err(refused()), + Some(_) => (), + } + self.check()?; + if receipt.exec.is_some() { + fs::remove_file(self.path(&receipt.session, true)).map_err(|_| refused())?; + File::open(&self.root) + .and_then(|f| f.sync_all()) + .map_err(|_| refused())?; + } + // A crash between these removals retains an intent requiring absence or + // a stopped boot. Never infer completion from a missing exec record. + fs::remove_file(self.path(&receipt.session, false)).map_err(|_| refused())?; + File::open(&self.root) + .and_then(|f| f.sync_all()) + .map_err(|_| refused())?; + self.check()?; + Ok(true) + } fn open(candidate: &Candidate, create: bool) -> Result, CandidateError> { let root = candidate.state_root.join("run/exec-sessions"); if !create { @@ -138,7 +189,7 @@ impl Store { let value = Self { root, identity: (metadata.dev(), metadata.ino()), - lock, + lock: Some(lock), }; value.check()?; if create { @@ -155,7 +206,11 @@ impl Store { Ok(Some(value)) } fn check(&self) -> Result<(), CandidateError> { - self.lock.verify_path(&self.root).map_err(|_| refused())?; + if let Some(lock) = &self.lock { + lock.verify_path(&self.root).map_err(|_| refused())?; + } else { + state::check_private_directory(&self.root).map_err(|_| refused())?; + } let metadata = fs::symlink_metadata(&self.root).map_err(|_| refused())?; if !metadata.is_dir() || (metadata.dev(), metadata.ino()) != self.identity { return Err(refused()); @@ -184,7 +239,63 @@ impl Store { absent()?; state::write_before_rename(&path, receipt, &absent).map_err(|_| refused()) } - fn snapshot(&self) -> Result<(Vec, Vec), CandidateError> { + fn snapshot(&self) -> Result { + if self.lock.is_some() { + return self.snapshot_once(); + } + self.observe_snapshot(&mut || self.snapshot_once()) + } + // Coarse-timestamp filesystems can still yield a stale/refused presentation; + // these stamps never grant mutation or absence authority. + fn stamp(&self) -> Result<(i64, i64, i64, i64), CandidateError> { + self.check()?; + let metadata = fs::symlink_metadata(&self.root).map_err(|_| refused())?; + Ok(( + metadata.mtime(), + metadata.mtime_nsec(), + metadata.ctime(), + metadata.ctime_nsec(), + )) + } + fn observe_snapshot( + &self, + read: &mut dyn FnMut() -> Result, + ) -> Result { + for _ in 0..3 { + let before = self.stamp()?; + let snapshot = read(); + let after = self.stamp()?; + if before == after + && !snapshot + .as_ref() + .is_err_and(|e| e.code == "session_store_changed") + { + return snapshot; + } + } + Err(changed()) + } + fn entry_error(&self, error: std::io::Error) -> CandidateError { + if self.lock.is_none() && error.kind() == std::io::ErrorKind::NotFound { + changed() + } else { + refused() + } + } + fn read_record( + &self, + path: &Path, + ) -> Result { + state::read_bounded(path, 4096).map_err(|_| { + // read_bounded redacts I/O errors. Only a proven vanished entry is + // transitional; malformed, aliased or inaccessible final files refuse. + match fs::symlink_metadata(path) { + Err(error) => self.entry_error(error), + Ok(_) => refused(), + } + }) + } + fn snapshot_once(&self) -> Result { self.check()?; let mut intents = BTreeMap::new(); let mut execs = BTreeMap::new(); @@ -200,8 +311,7 @@ impl Store { continue; } if name.starts_with("stopped-") && name.ends_with(".json") { - let proof: StoppedBoot = - state::read_bounded(&entry.path(), 4096).map_err(|_| refused())?; + let proof: StoppedBoot = self.read_record(&entry.path())?; if proof.name()? != name { return Err(refused()); } @@ -222,7 +332,8 @@ impl Store { if !receipt_name && !proof_name { return Err(refused()); } - let metadata = fs::symlink_metadata(entry.path()).map_err(|_| refused())?; + let metadata = + fs::symlink_metadata(entry.path()).map_err(|e| self.entry_error(e))?; // These are our unpublished value-free writes. No effect may // follow until the final rename and directory fsync return. if !metadata.is_file() @@ -236,8 +347,7 @@ impl Store { // paths prune these files under the lock before publishing. continue; } - let receipt: Receipt = - state::read_bounded(&entry.path(), 4096).map_err(|_| refused())?; + let receipt: Receipt = self.read_record(&entry.path())?; if !receipt.valid() || entry.path() != self.path(&receipt.session, receipt.exec.is_some()) { @@ -327,8 +437,12 @@ impl Intent { } } -pub fn admit(candidate: &Candidate, binding: Binding) -> Result { - if !binding.valid() { +pub fn admit( + candidate: &Candidate, + binding: Binding, + session: &str, +) -> Result { + if !binding.valid() || !environment_recovery::uuid(session) { return Err(refused()); } let store = Store::open(candidate, true)?.ok_or_else(refused)?; @@ -336,27 +450,12 @@ pub fn admit(candidate: &Candidate, binding: Binding) -> Result= LIMIT { return Err(CandidateError::new( "session_receipt_limit", - "The native home has 1024 retained interactive sessions. Run hack down for a project with retained sessions before opening another session.", + "The native home has 1024 retained interactive sessions. List with hack exec-sessions; try hack exec-sessions --reconcile to clear proven exits, or hack down for the affected project.", )); } - let mut random = [0u8; 16]; - File::open("/dev/urandom") - .and_then(|mut f| f.read_exact(&mut random)) - .map_err(|_| refused())?; - random[6] = (random[6] & 0x0f) | 0x40; - random[8] = (random[8] & 0x3f) | 0x80; - let text: String = random.iter().map(|b| format!("{b:02x}")).collect(); - let session = format!( - "{}-{}-{}-{}-{}", - &text[..8], - &text[8..12], - &text[12..16], - &text[16..20], - &text[20..] - ); let receipt = Receipt { version: 1, - session, + session: session.into(), binding, exec: None, }; @@ -364,12 +463,29 @@ pub fn admit(candidate: &Candidate, binding: Binding) -> Result Result, CandidateError> { - Store::open(candidate, false)?.map_or(Ok(Vec::new()), |s| s.list()) + Store::observe(candidate)?.map_or(Ok(Vec::new()), |s| s.list()) +} + +/// Caller has observed normal stream closure and validated this exact exec's +/// exit against the same container generation, owner and boot. +#[cfg(any(target_os = "macos", test))] +pub(in crate::provider) fn retire_completed( + candidate: &Candidate, + receipt: &Receipt, +) -> Result<(), CandidateError> { + if receipt.exec.is_none() { + return Err(refused()); + } + Store::open(candidate, false)? + .ok_or_else(refused)? + .retire(receipt)?; + Ok(()) } pub(crate) enum RetirementProof { Unknown, ContainerAbsent, + ExecExitedValidated, BootStopped, } @@ -406,22 +522,31 @@ pub(crate) fn after_boot_stopped(candidate: &Candidate, owner: &state::Owner) { #[cfg(target_os = "macos")] fn cleanup_warning() { - eprintln!( + cleanup_notice(format_args!( "session_cleanup_uncertain: interactive receipts were retained; container cleanup was not rolled back. Retry hack down for this project and instance." - ); + )); } -/// Only exact same-boot absence or the existing clean-stop receipt retires a +#[cfg(target_os = "macos")] +fn cleanup_notice(message: std::fmt::Arguments<'_>) { + use std::io::Write; + // The foreground reader may already be gone. Diagnostics must not turn + // successfully completed cleanup into a panic on a broken stderr pipe. + let _ = writeln!(std::io::stderr().lock(), "{message}"); +} + +/// Exact validated exit, same-boot absence or a clean-stop receipt retires a /// session. A new boot cannot establish absence in an earlier boot's Engine. #[cfg(target_os = "macos")] -pub(crate) fn reconcile_observed(candidate: &Candidate) -> Result { +pub fn reconcile_observed(candidate: &Candidate) -> Result { if retained(candidate)?.is_empty() { return Ok(0); } let owner = state::Owner::load(candidate)?; let observer = super::engine::Observer::connect(candidate); let deadline = Instant::now() + Duration::from_secs(2); - reconcile(candidate, &|binding| { + reconcile(candidate, &|receipt| { + let binding = &receipt.binding; if binding.owner != owner.token { return Ok(RetirementProof::Unknown); } @@ -445,6 +570,12 @@ pub(crate) fn reconcile_observed(candidate: &Candidate) -> Result Ok(RetirementProof::ContainerAbsent), + Ok(_) if receipt.exec.is_some() => { + match observer.interactive_finished(receipt, deadline) { + Ok(Some(_)) => Ok(RetirementProof::ExecExitedValidated), + _ => Ok(RetirementProof::Unknown), + } + } Ok(_) => Ok(RetirementProof::Unknown), Err(error) => Err(error), } @@ -454,8 +585,22 @@ pub(crate) fn reconcile_observed(candidate: &Candidate) -> Result (), + Ok(records) => { + cleanup_notice(format_args!( + "session_cleanup_uncertain: {} interactive session receipts remain in the native home; run hack exec-sessions --reconcile, or hack down for their projects.", + records.len() + )); + for record in records { + cleanup_notice(format_args!( + "session {} run {} service {}", + record.session, record.binding.run, record.binding.service + )); + } + } + Err(_) => cleanup_warning(), } } @@ -464,7 +609,7 @@ pub(crate) fn after_cleanup(candidate: &Candidate) { /// performs no Engine effects and does not turn malformed receipts into absence. pub(crate) fn reconcile( candidate: &Candidate, - proof: &dyn Fn(&Binding) -> Result, + proof: &dyn Fn(&Receipt) -> Result, ) -> Result { let Some(store) = Store::open(candidate, false)? else { return Ok(0); @@ -479,7 +624,7 @@ pub(crate) fn reconcile( break; } if !stopped.iter().any(|p| p.covers(&receipt.binding)) - && matches!(proof(&receipt.binding)?, RetirementProof::Unknown) + && matches!(proof(&receipt)?, RetirementProof::Unknown) { continue; } @@ -492,20 +637,7 @@ pub(crate) fn reconcile( if !current.contains(&receipt) { continue; } - store.check()?; - if receipt.exec.is_some() { - fs::remove_file(store.path(&receipt.session, true)).map_err(|_| refused())?; - File::open(&store.root) - .and_then(|f| f.sync_all()) - .map_err(|_| refused())?; - } - // Exec removed first: a crash here retains a valid intent. It still - // requires the absence proof on a later reconciliation. - fs::remove_file(store.path(&receipt.session, false)).map_err(|_| refused())?; - File::open(&store.root) - .and_then(|f| f.sync_all()) - .map_err(|_| refused())?; - store.check()?; + store.retire(&receipt)?; count += 1; } // Keep a stopped-boot proof across partial reconciliation. Remove it only @@ -535,6 +667,18 @@ pub(crate) fn reconcile( #[cfg(test)] mod tests { use super::*; + fn admit(candidate: &Candidate, binding: Binding) -> Result { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(1); + super::admit( + candidate, + binding, + &format!( + "00000000-0000-4000-8000-{:012x}", + NEXT.fetch_add(1, Ordering::Relaxed) + ), + ) + } fn fixture() -> (PathBuf, Candidate, Binding) { let root = crate::private_test_root(&std::env::temp_dir(), "interactive-session"); let candidate = Candidate::discover(&root).unwrap(); @@ -566,10 +710,10 @@ mod tests { assert_eq!( reconcile(&candidate, &|_| Ok(RetirementProof::Unknown)).unwrap(), 0 - ); // EOF/exit is not proof. + ); // EOF alone is not proof. assert_eq!( reconcile(&candidate, &|b| { - assert_eq!(b, &binding); + assert_eq!(&b.binding, &binding); Ok(RetirementProof::ContainerAbsent) }) .unwrap(), @@ -578,6 +722,100 @@ mod tests { assert!(retained(&candidate).unwrap().is_empty()); fs::remove_dir_all(root).unwrap(); } + #[test] + fn completed_receipt_retirement_is_exact_and_does_not_accumulate() { + let (root, candidate, binding) = fixture(); + let unknown = admit(&candidate, binding.clone()).unwrap(); + let receipt = admit(&candidate, binding.clone()) + .unwrap() + .record_exec(&candidate, &"e".repeat(64)) + .unwrap(); + let mut changed = receipt.clone(); + changed.exec = Some("f".repeat(64)); + assert!(retire_completed(&candidate, &changed).is_err()); + assert!(retire_completed(&candidate, unknown.receipt()).is_err()); + retire_completed(&candidate, &receipt).unwrap(); + retire_completed(&candidate, &receipt).unwrap(); // Concurrent reconciliation is idempotent. + assert_eq!(retained(&candidate).unwrap(), vec![unknown.receipt.clone()]); + for _ in 0..8 { + let receipt = admit(&candidate, binding.clone()) + .unwrap() + .record_exec(&candidate, &"e".repeat(64)) + .unwrap(); + retire_completed(&candidate, &receipt).unwrap(); + } + assert_eq!(retained(&candidate).unwrap().len(), 1); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn listing_never_takes_mutation_lock_and_duplicate_id_never_overwrites() { + let (root, candidate, binding) = fixture(); + let intent = admit(&candidate, binding.clone()).unwrap(); + assert!(super::admit(&candidate, binding, &intent.receipt.session).is_err()); + let store = Store::open(&candidate, false).unwrap().unwrap(); + let before = fs::metadata(&store.root).unwrap().mtime_nsec(); + assert_eq!(retained(&candidate).unwrap(), vec![intent.receipt.clone()]); + assert_eq!(fs::metadata(&store.root).unwrap().mtime_nsec(), before); + drop(store); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn observation_retries_renames_and_retirement_but_never_masks_corruption() { + let (root, candidate, binding) = fixture(); + let intent = admit(&candidate, binding).unwrap(); + let store = Store::observe(&candidate).unwrap().unwrap(); + let receipt = intent.receipt.clone(); + let mut intent = Some(intent); + let mut reads = 0; + let snapshot = store + .observe_snapshot(&mut || { + reads += 1; + if let Some(intent) = intent.take() { + intent.withdraw(&candidate).unwrap(); + return store + .read_record::(&store.path(&receipt.session, false)) + .map(|r| (vec![r], vec![])); + } + store.snapshot_once() + }) + .unwrap(); + assert_eq!(reads, 2); + assert!(snapshot.0.is_empty()); + let mut reads = 0; + let error = store + .observe_snapshot(&mut || { + reads += 1; + Err(changed()) + }) + .unwrap_err(); + assert_eq!(reads, 3); + assert_eq!(error.code, "session_store_changed"); + let error = store.observe_snapshot(&mut || Err(refused())).unwrap_err(); + assert_eq!(error.code, "session_receipt_refused"); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn explicit_reconciliation_can_clear_a_proven_exit_without_container_removal() { + let (root, candidate, binding) = fixture(); + admit(&candidate, binding) + .unwrap() + .record_exec(&candidate, &"e".repeat(64)) + .unwrap(); + assert_eq!( + reconcile(&candidate, &|receipt| { + assert!(receipt.exec.is_some()); + Ok(RetirementProof::ExecExitedValidated) + }) + .unwrap(), + 1 + ); + assert!(retained(&candidate).unwrap().is_empty()); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn corrupt_or_foreign_evidence_refuses_without_deletion() { let (root, candidate, binding) = fixture(); diff --git a/packages/runtime-core/src/provider/interactive_terminal.rs b/packages/runtime-core/src/provider/interactive_terminal.rs index 9b53466f4..7e556eae8 100644 --- a/packages/runtime-core/src/provider/interactive_terminal.rs +++ b/packages/runtime-core/src/provider/interactive_terminal.rs @@ -98,6 +98,8 @@ impl Signals { libc::SIGHUP, libc::SIGQUIT, libc::SIGTSTP, + libc::SIGTTIN, + libc::SIGTTOU, ] { // SAFETY: these C structs have valid zero representations; the // handler touches only a lock-free atomic and retains no pointers. @@ -319,6 +321,10 @@ impl Terminal { } pub fn restore(&mut self) -> Result<(), CandidateError> { if self.raw { + // The shell may reclaim the foreground after the frontend dies. + // POSIX permits a background tcsetattr when SIGTTOU is blocked. + // Restore this thread's exact mask even when tcsetattr fails. + let _background = BackgroundRestore::new()?; // SAFETY: saved came from this terminal; no pointers are retained. if unsafe { libc::tcsetattr(self.file.as_raw_fd(), libc::TCSANOW, &self.saved) } != 0 { return Err(CandidateError::new( @@ -438,6 +444,29 @@ impl Terminal { } } } + +struct BackgroundRestore(libc::sigset_t); +impl BackgroundRestore { + fn new() -> Result { + // SAFETY: initialized signal sets, scoped to the terminal I/O thread. + let mut blocked: libc::sigset_t = unsafe { std::mem::zeroed() }; + let mut previous: libc::sigset_t = unsafe { std::mem::zeroed() }; + unsafe { + libc::sigemptyset(&mut blocked); + libc::sigaddset(&mut blocked, libc::SIGTTOU); + } + if unsafe { libc::pthread_sigmask(libc::SIG_BLOCK, &blocked, &mut previous) } != 0 { + return Err(refused()); + } + Ok(Self(previous)) + } +} +impl Drop for BackgroundRestore { + fn drop(&mut self) { + // SAFETY: restores the calling thread's mask captured by this guard. + unsafe { libc::pthread_sigmask(libc::SIG_SETMASK, &self.0, std::ptr::null_mut()) }; + } +} impl Drop for Terminal { fn drop(&mut self) { let _ = self.restore(); @@ -557,7 +586,7 @@ mod tests { ); assert_eq!( String::from_utf8_lossy(&output.stdout), - "Native terminal: flood, resize, idle leash and signal restoration passed\n" + "Native terminal: flood, resize, idle leash, signal and background restoration passed\n" ); } diff --git a/scripts/check-native-guest.sh b/scripts/check-native-guest.sh new file mode 100644 index 000000000..586c53dd6 --- /dev/null +++ b/scripts/check-native-guest.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Build the same guest artifact as build-native-candidate, including shared sources. +set -eu +repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P) +cd "$repo" +CARGO_INCREMENTAL=0 \ +CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS='-C link-self-contained=no' \ +CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER="$repo/scripts/zig-aarch64-musl-linker" \ +cargo build --locked --release --jobs 2 --bin hack-relay-guest \ + --manifest-path packages/relay-guest/Cargo.toml \ + --target aarch64-unknown-linux-musl \ + --target-dir "${HACK_GUEST_CHECK_TARGET_DIR:-.hack-local/native-guest-target}" +python3 scripts/verify-native-relay.py "${HACK_GUEST_CHECK_TARGET_DIR:-.hack-local/native-guest-target}/aarch64-unknown-linux-musl/release/hack-relay-guest" diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index 68485ebf8..5f5030606 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -144,7 +144,7 @@ const contracts: readonly ModelContract[] = [ { name: "interactive-session", module: "Session", - states: 180, + states: 459, invariant: "NoReplay", action: "ReplayStart", fields: [ @@ -155,6 +155,20 @@ const contracts: readonly ModelContract[] = [ "capable = FALSE", ], additionalControls: [ + { + name: "failed-inspect", + negative: true, + invariant: "RetiredWithProof", + action: "Retire", + fields: [ + 'stage = "retired"', + "inspectFailed = TRUE", + "exitedValidated = FALSE", + "stopped = FALSE", + "absent = FALSE", + ], + }, + { name: "eof-retirement", negative: true, diff --git a/src/backends/native-interactive-exec.ts b/src/backends/native-interactive-exec.ts index f0e192b43..4b982dbbd 100644 --- a/src/backends/native-interactive-exec.ts +++ b/src/backends/native-interactive-exec.ts @@ -20,11 +20,22 @@ function refused(): never { /** Match Compose's terminal-driven exec selection; pipes retain finite exec. */ export function nativeInteractiveRequested(): boolean { - return Boolean( - process.stdin.isTTY && - process.stdout.isTTY && - process.env.HACK_NO_INTERACTIVE !== "1" - ); + if ( + !(process.stdin.isTTY && process.stdout.isTTY) || + process.env.HACK_NO_INTERACTIVE === "1" + ) { + return false; + } + try { + const terminal = openTerminalControl(); + try { + return terminal.foreground() === terminal.group(); + } finally { + terminal.close(); + } + } catch { + return false; + } } type Report = { @@ -108,7 +119,8 @@ export async function nativeInteractiveExec( ) { return refused(); } - const remedy = `hack down --path ${quote(opts.projectRoot)}${opts.branch ? ` --branch ${quote(opts.branch)}` : ""}`; + const session = crypto.randomUUID(); + const remedy = `hack exec-sessions --reconcile; if still retained, hack down --path ${quote(opts.projectRoot)}${opts.branch ? ` --branch ${quote(opts.branch)}` : ""}`; // Cancellation closes the child leash; it must not suppress the subsequent // read-only receipt query that reports an uncertain admission. return await withPinnedRuntimeBinary( @@ -129,7 +141,7 @@ export async function nativeInteractiveExec( return refused(); } ({ report, exit } = await runInteractiveChild( - spawnInteractiveChild(opts), + spawnInteractiveChild(opts, session), opts )); } finally { @@ -144,7 +156,9 @@ export async function nativeInteractiveExec( } return await finishInteractiveReport({ opts, - report, + report: + report?.session && report.session !== session ? undefined : report, + session, exit, restorationFailed, remedy, @@ -154,7 +168,10 @@ export async function nativeInteractiveExec( ); } -function spawnInteractiveChild(opts: InteractiveOptions): ChildProcess { +function spawnInteractiveChild( + opts: InteractiveOptions, + session: string +): ChildProcess { const env: Record = {}; for (const key of ["PATH", "HOME", "TMPDIR", "USER", "LOGNAME"]) { const value = process.env[key]; @@ -169,6 +186,8 @@ function spawnInteractiveChild(opts: InteractiveOptions): ChildProcess { opts.runtime.home, "graph", "exec-interactive", + "--session-id", + session, "--run-id", opts.run, "--service", @@ -286,7 +305,8 @@ async function runInteractiveChild( async function reportRetainedSessions( opts: InteractiveOptions, check: () => void, - remedy: string + remedy: string, + session: string ): Promise { const retained = await (async () => { check(); @@ -296,6 +316,8 @@ async function reportRetainedSessions( args: [ "graph", "interactive-sessions", + "--session", + session, "--run-id", opts.run, "--service", @@ -307,27 +329,32 @@ async function reportRetainedSessions( })().catch(() => null); if ( isRecord(retained) && - keys(retained, "sessions,version") && + keys(retained, "reconciled,sessions,version") && retained.version === 1 && + retained.reconciled === null && Array.isArray(retained.sessions) && - retained.sessions.length <= 1024 && + retained.sessions.length <= 1 && retained.sessions.every( - (id: unknown) => typeof id === "string" && UUID.test(id) + (r: unknown) => + isRecord(r) && + keys(r, "has_exec,run,service,session") && + r.session === session && + r.run === opts.run && + r.service === opts.service && + typeof r.has_exec === "boolean" ) ) { - if (retained.sessions.length > 0) { - process.stderr.write( - "Retained sessions for this service (the interrupted session may be among them):\n" - ); - } - for (const id of retained.sessions) { - process.stderr.write( - `session_cleanup_uncertain: session ${id}; cleanup: ${remedy}\n` - ); - } + process.stderr.write( + retained.sessions.length === 0 + ? `No receipt is currently retained for session ${session}. +` + : `session_cleanup_uncertain: session ${session}; cleanup: ${remedy} +` + ); } else { process.stderr.write( - `session_cleanup_uncertain: session evidence unavailable; cleanup: ${remedy}\n` + `session_cleanup_uncertain: evidence unavailable for session ${session}; cleanup: ${remedy} +` ); } } @@ -335,6 +362,7 @@ async function reportRetainedSessions( async function finishInteractiveReport({ opts, report, + session, exit, restorationFailed, remedy, @@ -342,6 +370,7 @@ async function finishInteractiveReport({ }: { opts: InteractiveOptions; report?: Report; + session: string; exit: number; restorationFailed: boolean; remedy: string; @@ -349,30 +378,31 @@ async function finishInteractiveReport({ }): Promise { if (report?.failure === "session_receipt_limit") { process.stderr.write( - "session_receipt_limit: the native home has 1024 retained interactive sessions; run hack down for a project with retained sessions before opening another.\n" + "session_receipt_limit: the native home has 1024 retained interactive sessions; list with hack exec-sessions, then try hack exec-sessions --reconcile. Use hack down for the affected project if evidence remains.\n" ); return 1; } - if (!report || (report.failure && !report.session)) { + if (!report) { // SIGKILL cannot send a final report. Query only value-free receipts after // the child is reaped; never replay its start to recover an outcome. - await reportRetainedSessions(opts, check, remedy); - if (report?.failure) { - process.stderr.write( - `Native interactive exec ended (${report.failure}).\n` - ); - } + await reportRetainedSessions(opts, check, remedy, session); return exit === 0 ? 1 : exit; } if (report.retained) { - process.stderr.write( - `session_cleanup_uncertain: session ${report.session}; cleanup: ${remedy}\n` - ); + await reportRetainedSessions(opts, check, remedy, session); } if (report.failure) { - process.stderr.write( - `Native interactive exec ended (${report.failure}).\n` - ); + process.stderr.write(`${interactiveRefusalMessage(report.failure)}\n`); } return restorationFailed && report.code === 0 ? 1 : report.code; } + +function interactiveRefusalMessage(code: string): string { + if ( + code === "interactive_exec_environment_required" || + code === "graph_service_exec_launcher" + ) { + return `Interactive exec needs the current managed-environment launcher (${code}). Use HACK_NO_INTERACTIVE=1 hack exec for finite exec, or restart the service.`; + } + return `Interactive exec could not confirm admission or completion (${code}); no request was replayed. Check hack ps, or use HACK_NO_INTERACTIVE=1 hack exec for a new finite invocation.`; +} diff --git a/src/cli/spec.ts b/src/cli/spec.ts index 063875158..869721192 100644 --- a/src/cli/spec.ts +++ b/src/cli/spec.ts @@ -9,6 +9,7 @@ import { devCommand } from "../commands/dev.ts"; import { dispatchCommand } from "../commands/dispatch.ts"; import { doctorCommand } from "../commands/doctor.ts"; import { envCommand, hostCommand } from "../commands/env.ts"; +import { execSessionsCommand } from "../commands/exec-sessions.ts"; import { gatewayCommand } from "../commands/gateway.ts"; import { globalCommand } from "../commands/global.ts"; import { helpCommand } from "../commands/help.ts"; @@ -80,6 +81,7 @@ export const CLI_SPEC = defineCli({ tuiCommand, runCommand, execCommand, + execSessionsCommand, commandsCommand, logsCommand, openCommand, diff --git a/src/commands/exec-sessions.ts b/src/commands/exec-sessions.ts new file mode 100644 index 000000000..24e523a2f --- /dev/null +++ b/src/commands/exec-sessions.ts @@ -0,0 +1,140 @@ +import { + invokeNativeRuntime, + resolveNativeRuntimeSelection, +} from "../backends/native-runtime-client.ts"; +import { + type CliContext, + type CommandArgs, + defineCommand, + withHandler, +} from "../cli/command.ts"; +import { ClosedCliError } from "../lib/cli-result.ts"; +import { isRecord } from "../lib/guards.ts"; +import { keys } from "../lib/native-compose-private-state.ts"; + +const options = [ + { + name: "reconcile", + type: "boolean", + long: "--reconcile", + description: + "Retire only sessions with proven exit, container absence or a stopped boot", + }, + { + name: "json", + type: "boolean", + long: "--json", + description: "Print value-free session evidence as JSON", + }, +] as const; +const spec = defineCommand({ + name: "exec-sessions", + summary: "List retained native interactive exec sessions in this native home", + group: "Diagnostics", + options, + positionals: [], + subcommands: [], +} as const); +const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; +const RUN = /^[a-f0-9]{32}$/; +const SERVICE = /^[a-zA-Z0-9_.-]{1,128}$/; + +export function parseExecSessions(value: unknown) { + if ( + !( + isRecord(value) && + keys(value, "reconciled,sessions,version") && + value.version === 1 && + (value.reconciled === null || typeof value.reconciled === "boolean") && + Array.isArray(value.sessions) && + value.sessions.length <= 1024 + ) + ) { + throw new ClosedCliError( + "Native session evidence is unavailable; nothing was cleared." + ); + } + const sessions = value.sessions.map((r: unknown) => { + if ( + !( + isRecord(r) && + keys(r, "has_exec,run,service,session") && + typeof r.session === "string" && + UUID.test(r.session) && + typeof r.run === "string" && + RUN.test(r.run) && + typeof r.service === "string" && + SERVICE.test(r.service) && + typeof r.has_exec === "boolean" + ) + ) { + throw new ClosedCliError( + "Native session evidence is invalid; values omitted." + ); + } + return { + session: r.session, + run: r.run, + service: r.service, + has_exec: r.has_exec, + }; + }); + if (new Set(sessions.map((r) => r.session)).size !== sessions.length) { + throw new ClosedCliError( + "Native session evidence contains duplicate identities." + ); + } + return { version: 1, sessions, reconciled: value.reconciled }; +} + +async function handle({ + ctx, + args, +}: { + readonly ctx: CliContext; + readonly args: CommandArgs; +}): Promise { + const runtime = resolveNativeRuntimeSelection(); + if (!runtime) { + throw new ClosedCliError( + "exec-sessions requires the native candidate CLI and its selected native home." + ); + } + const result = parseExecSessions( + await invokeNativeRuntime({ + runtime, + cwd: ctx.cwd, + args: [ + "graph", + "interactive-sessions", + ...(args.options.reconcile ? ["--reconcile"] : []), + "--json", + ], + timeoutMs: 15_000, + }) + ); + if (args.options.json) { + console.log(JSON.stringify(result)); + } else { + console.log( + `${result.sessions.length} retained interactive session(s) in this native home.` + ); + for (const r of result.sessions) { + console.log( + `${r.service} run=${r.run} session=${r.session} ${r.has_exec ? "exec recorded" : "intent only"}` + ); + } + if (result.sessions.length > 0) { + console.log( + "Run hack exec-sessions --reconcile to clear proven exits; otherwise use hack down for the affected project." + ); + } + if (result.reconciled === false) { + console.log( + "Reconciliation was incomplete; the listing above reflects the evidence that remains." + ); + } + } + return result.reconciled === false ? 1 : 0; +} +export const execSessionsCommand = withHandler(spec, handle); diff --git a/tests/fixtures/native-interactive-frontend.py b/tests/fixtures/native-interactive-frontend.py index ad6bce652..2e9881dde 100644 --- a/tests/fixtures/native-interactive-frontend.py +++ b/tests/fixtures/native-interactive-frontend.py @@ -15,6 +15,9 @@ def hold_session(): """Stand in for the shell, keeping Darwin's controlling tty alive.""" control = socket.socket(fileno=int(sys.argv[2])) + terminal = os.open("/dev/tty", os.O_RDWR) + background = sys.argv[3] == "--background" + command_index = 4 if background else 3 gate_read, gate_write = os.pipe() child = os.fork() if child == 0: @@ -24,18 +27,22 @@ def hold_session(): assert os.read(gate_read, 1) == b"G" os.close(gate_read) try: - os.execvpe(sys.argv[3], sys.argv[3:], os.environ) + os.execvpe(sys.argv[command_index], sys.argv[command_index:], os.environ) except BaseException: os._exit(127) os.close(gate_read) os.setpgid(child, child) previous = signal.signal(signal.SIGTTOU, signal.SIG_IGN) - os.tcsetpgrp(0, child) + if not background: + os.tcsetpgrp(terminal, child) signal.signal(signal.SIGTTOU, previous) control.sendall(b"P" + child.to_bytes(4, "big")) os.write(gate_write, b"G") os.close(gate_write) _, status = os.waitpid(child, 0) + previous = signal.signal(signal.SIGTTOU, signal.SIG_IGN) + os.tcsetpgrp(terminal, os.getpgrp()) + signal.signal(signal.SIGTTOU, previous) code = os.waitstatus_to_exitcode(status) control.sendall(b"E" + code.to_bytes(4, "big", signed=True)) control.settimeout(15) @@ -84,8 +91,8 @@ def record(control, master, output, expected, timeout, mode): protected_keys = json.loads(sys.argv[2]) command = sys.argv[3:] assert all(os.environ.get(key) == "synthetic-parent-key-canary" for key in protected_keys) -for mode in ("normal", "child-kill", "frontend-kill", "cancel"): - for name in ("reader.pid", "reader.closed"): +for mode in ("normal", "child-kill", "frontend-kill", "cancel", "refused", "old-launcher", "retained", "cleared"): + for name in ("reader.pid", "reader.closed", "session.queried", "session.cleared"): try: os.unlink(os.path.join(root, name)) except FileNotFoundError: @@ -124,7 +131,7 @@ def session(): elif mode == "cancel": os.kill(frontend, signal.SIGTERM) code = record(control, master, output, b"E", 10, mode) - if mode == "normal": + if mode in ("normal", "retained", "cleared"): assert code == 7, code else: assert code != 0, code @@ -156,8 +163,19 @@ def session(): output.extend(os.read(master, 8192)) assert b"synthetic-private-envelope-canary" not in output assert b"synthetic-parent-key-canary" not in output - if mode != "frontend-kill": - assert b"session_cleanup_uncertain: session 00000000-0000-4000-8000-000000000001" in output, repr(output) + if mode in ("normal", "refused", "old-launcher"): + assert not os.path.exists(os.path.join(root, "session.queried")) + if mode == "old-launcher": + assert b"HACK_NO_INTERACTIVE=1" in output and b"restart the service" in output + assert b"session_cleanup_uncertain" not in output, repr(output) + elif mode == "cleared": + assert b"No receipt is currently retained for session" in output + assert b"without a result" not in output + assert b"session_cleanup_uncertain" not in output + elif mode != "frontend-kill": + with open(os.path.join(root, "session.id")) as file: + session_id = file.read().encode() + assert b"session_cleanup_uncertain: session " + session_id in output, repr(output) assert b"hack down --path" in output and b"--branch 'topic'" in output finally: body_failed = sys.exc_info()[0] is not None @@ -192,4 +210,32 @@ def session(): raise cleanup_error print(f"session cleanup also failed ({mode}): {type(cleanup_error).__name__}", file=sys.stderr) -print("Frontend PTY: four cases passed") +for selection, expected in (("foreground", True), ("disabled", False), + ("redirected", False), ("piped", False), ("background", False)): + master, slave = pty.openpty() + control, holder_control = socket.socketpair() + env = dict(os.environ) + env["HACK_NO_INTERACTIVE"] = "1" if selection == "disabled" else "0" + process = subprocess.Popen( + [sys.executable, os.path.abspath(__file__), "--hold-session", str(holder_control.fileno()), + *(["--background"] if selection == "background" else []), *command, root, "selection-" + selection], + cwd=root, stdin=subprocess.DEVNULL if selection == "piped" else slave, + stdout=subprocess.DEVNULL if selection == "redirected" else slave, stderr=slave, + preexec_fn=session, pass_fds=(holder_control.fileno(), slave), env=env) + holder_control.close() + output = bytearray() + try: + record(control, master, output, b"P", 10, selection) + assert record(control, master, output, b"E", 10, selection) == 0, repr(output) + with open(os.path.join(root, "selection.json")) as file: + assert json.load(file) == expected, selection + control.sendall(b"F") + assert wait_draining(process, master, output, 5, selection) == 0 + finally: + if process.poll() is None: + process.kill() + wait_draining(process, master, output, 5, selection) + control.close() + os.close(master) + os.close(slave) +print("Frontend PTY: eight lifetime and five selection cases passed") diff --git a/tests/fixtures/native-interactive-frontend.ts b/tests/fixtures/native-interactive-frontend.ts index 3dae26962..0e358a038 100644 --- a/tests/fixtures/native-interactive-frontend.ts +++ b/tests/fixtures/native-interactive-frontend.ts @@ -1,9 +1,19 @@ -import { nativeInteractiveExec } from "../../src/backends/native-interactive-exec.ts"; +import { + nativeInteractiveExec, + nativeInteractiveRequested, +} from "../../src/backends/native-interactive-exec.ts"; const [root, mode] = process.argv.slice(2); if (!(root && mode)) { throw new Error("fixture arguments"); } +if (mode.startsWith("selection-")) { + await Bun.write( + `${root}/selection.json`, + JSON.stringify(nativeInteractiveRequested()) + ); + process.exit(0); +} process.exitCode = await nativeInteractiveExec({ runtime: { binary: `${root}/runtime`, home: root }, projectRoot: root, diff --git a/tests/fixtures/native-interactive-runtime.py b/tests/fixtures/native-interactive-runtime.py index 2bab9885d..2f10d0548 100644 --- a/tests/fixtures/native-interactive-runtime.py +++ b/tests/fixtures/native-interactive-runtime.py @@ -11,9 +11,12 @@ protected_keys = ["__PROTECTED_KEYS__"] # Replaced by the test from the production key set. assert "__PROTECTED_KEYS__" not in protected_keys assert not any(key in os.environ for key in protected_keys) -session = "00000000-0000-4000-8000-000000000001" +session = sys.argv[sys.argv.index("--session" if "interactive-sessions" in sys.argv else "--session-id") + 1] if "interactive-sessions" in sys.argv: - print(json.dumps({"version": 1, "sessions": [session]})) + with open("session.queried", "w") as file: + file.write(session) + sessions = [] if os.path.exists("session.cleared") else [{"session": session, "run": "a" * 32, "service": "web", "has_exec": True}] + print(json.dumps({"version": 1, "reconciled": None, "sessions": sessions})) sys.exit(0) control = socket.socket(fileno=3) length = control.recv(4) @@ -31,17 +34,28 @@ # Like the native guard, survive session-leader loss long enough to observe # the leash and restore. Rust's separate process fixture tests its real handler. signal.signal(signal.SIGHUP, lambda *_: None) +with open("session.id", "w") as file: + file.write(session) with open("reader.pid", "w") as file: file.write(str(os.getpid())) os.write(1, b"child-ready\n") mode = sys.argv[-1] -if mode == "normal": +if mode in ("refused", "old-launcher"): + code = "graph_service_exec_launcher" if mode == "old-launcher" else "interactive_exec_refused" + control.sendall(json.dumps({"version": 1, "outcome": "refused", "code": code, "session": None}).encode() + b"\n") + sys.exit(1) +if mode == "cleared": + with open("session.cleared", "w") as file: + file.write("cleared") +if mode in ("normal", "retained", "cleared"): control.sendall(json.dumps({"version": 1, "outcome": "completed", "completion": { - "session": session, "exit_code": 7, "retained": True}}).encode() + b"\n") + "session": session, "exit_code": 7, "retained": mode != "normal"}}).encode() + b"\n") # Deliberately leave raw mode: the frontend's independent snapshot owns it. sys.exit(7) assert select.select([control], [], [], 10)[0], "leash deadline" assert control.recv(1) == b"", "unexpected post-envelope data" +previous = signal.signal(signal.SIGTTOU, signal.SIG_IGN) termios.tcsetattr(0, termios.TCSANOW, original) +signal.signal(signal.SIGTTOU, previous) with open("reader.closed", "w") as file: file.write("closed") diff --git a/tests/fixtures/native-interactive-terminal.py b/tests/fixtures/native-interactive-terminal.py index 9296dc52b..a4c85b934 100644 --- a/tests/fixtures/native-interactive-terminal.py +++ b/tests/fixtures/native-interactive-terminal.py @@ -12,6 +12,62 @@ import struct +def wait_draining(process, master, timeout): + deadline = time.monotonic() + timeout + drained = 0 + while process.poll() is None: + assert time.monotonic() < deadline, "native terminal child exit deadline" + if select.select([master], [], [], .05)[0]: + drained += len(os.read(master, 8192)) + assert drained <= 1024 * 1024, "native terminal fixture output bound" + return process.returncode + + +def control_draining(control, master, expected): + deadline = time.monotonic() + 10 + while True: + assert time.monotonic() < deadline, "terminal control deadline" + ready, _, _ = select.select([control, master], [], [], .05) + if master in ready: + os.read(master, 8192) + if control in ready: + assert control.recv(1) == expected + return + + +def hold_native(): + """A shell process with a different group takes back the foreground.""" + gate = socket.socket(fileno=int(sys.argv[2])) + ready_read, ready_write = os.pipe() + child = os.fork() + if child == 0: + gate.close() + os.close(ready_write) + os.setpgid(0, 0) + assert os.read(ready_read, 1) == b"G" + os.close(ready_read) + os.execv(sys.argv[3], sys.argv[3:]) + os.close(ready_read) + os.setpgid(child, child) + previous = signal.signal(signal.SIGTTOU, signal.SIG_IGN) + os.tcsetpgrp(0, child) + os.write(ready_write, b"G") + os.close(ready_write) + gate.settimeout(15) + assert gate.recv(1) == b"B" + os.tcsetpgrp(0, os.getpgrp()) + assert os.tcgetpgrp(0) == os.getpgrp() + signal.signal(signal.SIGTTOU, previous) + gate.sendall(b"B") + _, status = os.waitpid(child, 0) + sys.exit(os.waitstatus_to_exitcode(status)) + + +if sys.argv[1] == "--hold-native": + hold_native() + sys.exit(0) + + def run(mode): master, slave = pty.openpty() # Unknown dimensions must produce a usable default before admission. @@ -20,6 +76,7 @@ def run(mode): control, child_control = socket.socketpair() engine, child_engine = socket.socketpair() finish, child_finish = socket.socketpair() + reclaim, child_reclaim = socket.socketpair() env = {"PATH": "/usr/bin:/bin", "HACK_INTERACTIVE_CHILD": mode, "HACK_INTERACTIVE_CONTROL": str(child_control.fileno()), "HACK_INTERACTIVE_ENGINE": str(child_engine.fileno()), @@ -29,16 +86,21 @@ def session(): os.setsid() fcntl.ioctl(slave, termios.TIOCSCTTY, 0) + command = [sys.argv[1], "--exact", "provider::interactive_terminal::tests::child_session", + "--nocapture", "--test-threads=1"] + if mode == "background": + command = [sys.executable, os.path.abspath(__file__), "--hold-native", + str(child_reclaim.fileno()), *command] process = subprocess.Popen( - [sys.argv[1], "--exact", "provider::interactive_terminal::tests::child_session", - "--nocapture", "--test-threads=1"], + command, stdin=slave, stdout=slave, stderr=slave, env=env, - pass_fds=(child_control.fileno(), child_engine.fileno(), child_finish.fileno(), slave), + pass_fds=(child_control.fileno(), child_engine.fileno(), child_finish.fileno(), child_reclaim.fileno(), slave), preexec_fn=session, ) child_control.close() child_engine.close() child_finish.close() + child_reclaim.close() control.sendall(bytes(4)) try: deadline = time.monotonic() + 10 @@ -74,15 +136,19 @@ def session(): got_input.extend(engine.recv(8192)) assert got_input == incoming and got_output == outgoing fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 43, 91, 0, 0)) - assert control.recv(1) == b"R", "resize was not observed" + control_draining(control, master, b"R") engine.shutdown(socket.SHUT_WR) + elif mode == "background": + reclaim.sendall(b"B") + control_draining(reclaim, master, b"B") + control.shutdown(socket.SHUT_WR) elif mode == "leash": control.shutdown(socket.SHUT_WR) elif mode == "signal": process.send_signal(signal.SIGTERM) else: raise AssertionError("unknown scenario") - assert control.recv(1) == b"D", "child did not finish and restore" + control_draining(control, master, b"D") before_read = termios.tcgetattr(slave) # Darwin sets PENDIN when TIOCSETA restores ICANON. The next tty read # processes it, as the shell would. Keep full equality after that read; @@ -105,18 +171,19 @@ def session(): ) # Keep the session leader alive until the tty read and comparison finish. finish.sendall(b"F") - assert process.wait(timeout=5) == 0 + assert wait_draining(process, master, 5) == 0 finally: if process.poll() is None: process.kill() - process.wait(timeout=5) + wait_draining(process, master, 5) control.close() engine.close() finish.close() + reclaim.close() os.close(master) os.close(slave) -for mode in ("flood", "leash", "signal"): +for mode in ("flood", "leash", "signal", "background"): run(mode) -print("Native terminal: flood, resize, idle leash and signal restoration passed") +print("Native terminal: flood, resize, idle leash, signal and background restoration passed") diff --git a/tests/models/tla/interactive-session/COVERAGE.md b/tests/models/tla/interactive-session/COVERAGE.md index 2ec2a2951..55ed502e1 100644 --- a/tests/models/tla/interactive-session/COVERAGE.md +++ b/tests/models/tla/interactive-session/COVERAGE.md @@ -1,10 +1,11 @@ # Interactive session receipt -`Session.tla` exhausts 180 states for one session, original/replacement boot and +`Session.tla` exhausts 459 states for one session, original/replacement boot and at most two start attempts. `NoReplay`, `WriteAhead` and `RetiredWithProof` hold. The `negative` control enables `ReplayStart` after a crash and must violate `NoReplay` with `starts = 2`. `eof-retirement` enables retirement on EOF and must -violate `RetiredWithProof` with neither stopped-boot nor container-absence proof. +violate `RetiredWithProof` with no validated-exit, stopped-boot or container-absence proof. +`failed-inspect` retires on a failed inspection and must violate the same invariant. `wrong-boot` deliberately accepts a replacement boot's stop proof and must fail the same invariant while the original boot's proof remains false. `StopBoot` is enabled only for the original boot; `StopReplacement` tracks the other proof @@ -16,16 +17,21 @@ the model, which has no owner variable. | Admit, Record | `interactive_session::admit`, `Intent::record_exec`: pending write, file fsync, rename, directory fsync. Torn pending files do not authorize effects; malformed final records refuse. | | Withdraw | Only the live `Intent` before create dispatch can withdraw; `known_undispatched_admission_can_withdraw_its_own_intent` preserves another admission. | | Create, Start | `Engine::interactive_start`: repeated freshness/guest/effect guards; exec ID recorded before exactly one start. Any error after create dispatch retains evidence. | -| Attach, EOF, Crash | Owner consumes its fd after SCM_RIGHTS send; terminal EOF or disconnect never withdraws the receipt. Handoff tests preserve early output and close rejected descriptors; process tests cover lost frontend and terminal restoration. | +| Attach, EOF, Crash | Owner consumes its fd after SCM_RIGHTS send; terminal EOF or disconnect alone never withdraws the receipt. Handoff tests preserve early output and close rejected descriptors; process tests cover lost frontend and terminal restoration. | | StopBoot, RemoveContainer, ReplaceBoot | `finish_absent_locked` publishes exact owner/boot proof before changing phase. `reconcile_observed` requires same boot and owner for container 404. New boot alone is unknown. | +| ExecExitedValidated, InspectFailed | Normal stream closure plus exact Engine exec/container/generation/owner/boot inspection retires silently; explicit reconciliation accepts the same validated exit. Failed inspection retains evidence. `exit_inspection_requires_a_known_status`, `completed_receipt_retirement_is_exact_and_does_not_accumulate` and `explicit_reconciliation_can_clear_a_proven_exit_without_container_removal` cover the boundary. | | Retire | Snapshot without holding the store lock during observation; reacquire and compare receipt before removal. Tests cover record advancement during proof and durable stopped-boot proof surviving owner replacement. | Atomic durable publication abstracts the pending/fsync/rename sequence; filesystem tests exercise retained and final names but do not simulate power failure. Exact identity, real Engine observations and the VM absence proof are assumptions. The model excludes multiple sessions, physical descriptor transfer, terminal job -control and scheduler fairness. There is no eventual-cleanup claim. A normal exec -exit can leave descendants and therefore retains its receipt until exact cleanup. +control and scheduler fairness. There is no eventual-cleanup claim. A validated normal exec +exit retires its receipt without making a claim about surviving descendants. +The frontend generates the session correlation UUID before spawn; admission never +overwrites a duplicate and a lost report queries only that UUID. Listing takes no +mutation lock and creates no files. Reconciliation reports remaining IDs after its +bounded pass; there is no eventual-clearing claim. Descriptor truncation is covered separately by `interactive_handoff` tests. A pure parser regression preserves Darwin's original ancillary header length while @@ -35,3 +41,15 @@ child and checks that refusal preserves standard streams and sentinel sockets. Darwin can install rights whose numbers are omitted from a truncated reply; the short-lived receiver exits to release those rights. The receipt remains uncertain and no start is replayed. Linux execution does not qualify Darwin's kernel path. + + +Darwin handshake writes temporarily set O_NONBLOCK on the exclusively owned Engine +socket and restore its exact flags on every send result. The 87 KB blocked-write +deadline and cancellation regressions need hosted macOS Candidate core execution. +The native PTY fixture drains during control/exit waits and verifies restoration +after a separate shell-holder group takes foreground ownership. Linux execution +does not qualify Darwin tty behavior. The guest release build is +`bun run check:native-guest` (Rust 1.97.1, Zig 0.15.2, ARM64 Linux musl), included +in Candidate core and the owner's pre-push gate; it compiles shared path-included +sources and verifies the resulting static ELF. Neither build nor model checks +replace the lead's real-Mac interactive acceptance matrix. diff --git a/tests/models/tla/interactive-session/Session.tla b/tests/models/tla/interactive-session/Session.tla index 051f9d5b6..fdc80c9e9 100644 --- a/tests/models/tla/interactive-session/Session.tla +++ b/tests/models/tla/interactive-session/Session.tla @@ -1,57 +1,69 @@ ------------------------------ MODULE Session ------------------------------ EXTENDS Naturals, TLC -CONSTANTS Replay, RetireOnEOF, WrongBoot -VARIABLES stage, intent, recorded, starts, capable, boot, stopped, absent, ended, replacementStopped -vars == <> -Init == /\ stage = "new" /\ intent = FALSE /\ recorded = FALSE +CONSTANTS Replay, RetireOnEOF, WrongBoot, RetireOnFailedInspect +VARIABLES exitedValidated, inspectFailed, stage, intent, recorded, starts, capable, boot, stopped, absent, ended, replacementStopped +vars == <> +Init == /\ exitedValidated = FALSE /\ inspectFailed = FALSE + /\ stage = "new" /\ intent = FALSE /\ recorded = FALSE /\ starts = 0 /\ capable = TRUE /\ boot = "original" /\ stopped = FALSE /\ absent = FALSE /\ ended = FALSE /\ replacementStopped = FALSE -TypeOK == /\ stage \in {"new", "admitted", "created", "recorded", "started", "attached", "ended", "abandoned", "retired", "withdrawn"} +TypeOK == /\ exitedValidated \in BOOLEAN /\ inspectFailed \in BOOLEAN + /\ stage \in {"new", "admitted", "created", "recorded", "started", "attached", "ended", "abandoned", "retired", "withdrawn"} /\ intent \in BOOLEAN /\ recorded \in BOOLEAN /\ capable \in BOOLEAN /\ starts \in 0..2 /\ boot \in {"original", "replacement"} /\ stopped \in BOOLEAN /\ absent \in BOOLEAN /\ ended \in BOOLEAN /\ replacementStopped \in BOOLEAN Admit == /\ stage = "new" /\ capable /\ ~stopped /\ ~absent /\ boot = "original" /\ intent' = TRUE /\ stage' = "admitted" - /\ UNCHANGED <> + /\ UNCHANGED <> Create == /\ stage = "admitted" /\ capable /\ ~stopped /\ ~absent /\ boot = "original" /\ stage' = "created" - /\ UNCHANGED <> + /\ UNCHANGED <> \* Known refusal before dispatch may withdraw only the live capability. Withdraw == /\ stage = "admitted" /\ capable /\ stage' = "withdrawn" /\ intent' = FALSE /\ capable' = FALSE - /\ UNCHANGED <> + /\ UNCHANGED <> Record == /\ stage = "created" /\ capable /\ recorded' = TRUE /\ stage' = "recorded" - /\ UNCHANGED <> + /\ UNCHANGED <> Start == /\ stage = "recorded" /\ capable /\ ~stopped /\ ~absent /\ boot = "original" /\ starts' = starts + 1 /\ stage' = "started" /\ capable' = FALSE - /\ UNCHANGED <> + /\ UNCHANGED <> Attach == /\ stage = "started" /\ stage' = "attached" - /\ UNCHANGED <> + /\ UNCHANGED <> EOF == /\ stage \in {"started", "attached"} /\ ended' = TRUE /\ stage' = "ended" - /\ UNCHANGED <> + /\ UNCHANGED <> Crash == /\ stage \notin {"new", "abandoned", "retired", "withdrawn"} /\ stage' = "abandoned" /\ capable' = FALSE - /\ UNCHANGED <> + /\ UNCHANGED <> \* Exact-boot proof is durable and survives later boot replacement. StopBoot == /\ boot = "original" /\ ~stopped /\ stopped' = TRUE - /\ UNCHANGED <> + /\ UNCHANGED <> RemoveContainer == /\ ~absent /\ boot = "original" /\ absent' = TRUE - /\ UNCHANGED <> + /\ UNCHANGED <> \* Changed boot alone grants no cleanup proof, even when the new Engine says 404. ReplaceBoot == /\ boot = "original" /\ boot' = "replacement" - /\ UNCHANGED <> -Retire == /\ intent /\ stage # "retired" /\ (stopped \/ absent \/ (RetireOnEOF /\ ended) \/ (WrongBoot /\ replacementStopped)) + /\ UNCHANGED <> +Retire == /\ intent /\ stage # "retired" /\ (stopped \/ absent \/ exitedValidated \/ (RetireOnFailedInspect /\ inspectFailed) \/ (RetireOnEOF /\ ended) \/ (WrongBoot /\ replacementStopped)) /\ stage' = "retired" /\ capable' = FALSE - /\ UNCHANGED <> + /\ UNCHANGED <> ReplayStart == /\ Replay /\ stage = "abandoned" /\ recorded /\ starts = 1 /\ stage' = "started" /\ starts' = 2 - /\ UNCHANGED <> + /\ UNCHANGED <> StopReplacement == /\ boot = "replacement" /\ ~replacementStopped /\ replacementStopped' = TRUE - /\ UNCHANGED <> -Next == Admit \/ Create \/ Withdraw \/ Record \/ Start \/ Attach \/ EOF \/ Crash \/ StopBoot \/ RemoveContainer \/ ReplaceBoot \/ StopReplacement \/ Retire \/ ReplayStart + /\ UNCHANGED <> +\* Engine inspection validates exact exec/container identity, generation, boot +\* and a non-running exec with exit code 0..255. No descendant claim is made. +ExecExitedValidated == + /\ recorded /\ starts = 1 /\ boot = "original" /\ ~exitedValidated + /\ exitedValidated' = TRUE + /\ UNCHANGED <> +InspectFailed == + /\ recorded /\ ~inspectFailed /\ ~exitedValidated + /\ inspectFailed' = TRUE + /\ UNCHANGED <> +Next == ExecExitedValidated \/ InspectFailed \/ Admit \/ Create \/ Withdraw \/ Record \/ Start \/ Attach \/ EOF \/ Crash \/ StopBoot \/ RemoveContainer \/ ReplaceBoot \/ StopReplacement \/ Retire \/ ReplayStart Spec == Init /\ [][Next]_vars WriteAhead == starts > 0 => intent /\ recorded NoReplay == starts <= 1 -RetiredWithProof == stage = "retired" => stopped \/ absent +RetiredWithProof == stage = "retired" => stopped \/ absent \/ exitedValidated ============================================================================= diff --git a/tests/models/tla/interactive-session/eof-retirement.cfg b/tests/models/tla/interactive-session/eof-retirement.cfg index 208054ea5..8604bdf4a 100644 --- a/tests/models/tla/interactive-session/eof-retirement.cfg +++ b/tests/models/tla/interactive-session/eof-retirement.cfg @@ -1,4 +1,5 @@ -CONSTANTS Replay = FALSE +CONSTANTS RetireOnFailedInspect = FALSE + Replay = FALSE RetireOnEOF = TRUE WrongBoot = FALSE SPECIFICATION Spec diff --git a/tests/models/tla/interactive-session/failed-inspect.cfg b/tests/models/tla/interactive-session/failed-inspect.cfg new file mode 100644 index 000000000..10584aead --- /dev/null +++ b/tests/models/tla/interactive-session/failed-inspect.cfg @@ -0,0 +1,7 @@ +CONSTANTS RetireOnFailedInspect = TRUE + Replay = FALSE + RetireOnEOF = FALSE + WrongBoot = FALSE +SPECIFICATION Spec +INVARIANTS TypeOK WriteAhead NoReplay RetiredWithProof +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/interactive-session/negative.cfg b/tests/models/tla/interactive-session/negative.cfg index dee425edf..ef5e0d9f5 100644 --- a/tests/models/tla/interactive-session/negative.cfg +++ b/tests/models/tla/interactive-session/negative.cfg @@ -1,4 +1,5 @@ -CONSTANTS Replay = TRUE +CONSTANTS RetireOnFailedInspect = FALSE + Replay = TRUE RetireOnEOF = FALSE WrongBoot = FALSE SPECIFICATION Spec diff --git a/tests/models/tla/interactive-session/positive.cfg b/tests/models/tla/interactive-session/positive.cfg index d4539ccf5..23b1387c1 100644 --- a/tests/models/tla/interactive-session/positive.cfg +++ b/tests/models/tla/interactive-session/positive.cfg @@ -1,4 +1,5 @@ -CONSTANTS Replay = FALSE +CONSTANTS RetireOnFailedInspect = FALSE + Replay = FALSE RetireOnEOF = FALSE WrongBoot = FALSE SPECIFICATION Spec diff --git a/tests/models/tla/interactive-session/wrong-boot.cfg b/tests/models/tla/interactive-session/wrong-boot.cfg index 1a8fa0dfc..724f4a6c2 100644 --- a/tests/models/tla/interactive-session/wrong-boot.cfg +++ b/tests/models/tla/interactive-session/wrong-boot.cfg @@ -1,4 +1,5 @@ -CONSTANTS Replay = FALSE +CONSTANTS RetireOnFailedInspect = FALSE + Replay = FALSE RetireOnEOF = FALSE WrongBoot = TRUE SPECIFICATION Spec diff --git a/tests/native-authored-project-exec.test.ts b/tests/native-authored-project-exec.test.ts index 894662868..edb74bbc1 100644 --- a/tests/native-authored-project-exec.test.ts +++ b/tests/native-authored-project-exec.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, spyOn, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import { createHash } from "node:crypto"; import { chmod, @@ -31,12 +31,20 @@ import { import { tryNativeAuthoredCommand } from "../src/lib/native-authored-command.ts"; import type { NativeComposeCommandOptions } from "../src/lib/native-compose-command.ts"; import { captureCompletedJobFixtureCommand } from "./e2e/scenarios/native-compose-adoption-job-worktrees.ts"; +import { restoreEnv } from "./helpers/env.ts"; import { awaitPidFile, createNativeAuthoredSettlement, } from "./helpers/native-authored-settlement.ts"; /** Fixture-owner rule: a case that spawns children passes its own budget. */ +let interactiveEnvironment: string | undefined; +beforeEach(() => { + interactiveEnvironment = process.env.HACK_NO_INTERACTIVE; + process.env.HACK_NO_INTERACTIVE = "1"; +}); +afterEach(() => restoreEnv("HACK_NO_INTERACTIVE", interactiveEnvironment)); + const DEFAULT_BUDGET_MS = 4000; /** The cancelled request child sleeps this long after writing its identity. */ const REQUEST_SLEEP_MS = 3000; diff --git a/tests/native-exec-sessions.test.ts b/tests/native-exec-sessions.test.ts new file mode 100644 index 000000000..c028d70a7 --- /dev/null +++ b/tests/native-exec-sessions.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { parseExecSessions } from "../src/commands/exec-sessions.ts"; + +const record = { + session: "00000000-0000-4000-8000-000000000001", + run: "a".repeat(32), + service: "web", + has_exec: true, +}; +test("session listing accepts current incomplete and empty reconciliation snapshots", () => { + for (const reconciled of [null, true, false]) { + expect( + parseExecSessions({ version: 1, sessions: [record], reconciled }) + ).toEqual({ + version: 1, + sessions: [record], + reconciled, + }); + expect( + parseExecSessions({ version: 1, sessions: [], reconciled }).sessions + ).toEqual([]); + } +}); +test("session listing rejects values, duplicate identities and malformed reports", () => { + for (const value of [ + { version: 1, sessions: [record, record], reconciled: null }, + { + version: 1, + sessions: [{ ...record, argv: ["private-canary"] }], + reconciled: null, + }, + { + version: 1, + sessions: [{ ...record, service: "private\ncanary" }], + reconciled: null, + }, + { + version: 1, + sessions: [{ ...record, has_exec: "yes" }], + reconciled: null, + }, + { version: 1, sessions: [record], reconciled: "yes" }, + ]) { + expect(() => parseExecSessions(value)).toThrow(); + } +}); +test("guest shared sources remain independent of the host provider", async () => { + const root = resolve("packages/relay-guest/src"); + const entry = await readFile(resolve(root, "main.rs"), "utf8"); + const paths = [...entry.matchAll(/#\[path = "([^"]+)"\]/g)].map( + (match) => match[1] + ); + expect(paths.length).toBeGreaterThanOrEqual(7); + for (const path of paths) { + const source = await readFile(resolve(root, path ?? ""), "utf8"); + expect(source).not.toMatch(/crate::provider\b/); + } + const ci = await readFile(".github/workflows/ci.yml", "utf8"); + expect(ci).toContain("sh scripts/check-native-guest.sh"); + const build = await readFile("scripts/check-native-guest.sh", "utf8"); + expect(build).toContain("--target aarch64-unknown-linux-musl"); + expect(build).toContain("--bin hack-relay-guest"); +}); diff --git a/tests/native-interactive-exec.test.ts b/tests/native-interactive-exec.test.ts index fbc9ca783..bb257fb52 100644 --- a/tests/native-interactive-exec.test.ts +++ b/tests/native-interactive-exec.test.ts @@ -122,7 +122,9 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( ]); expect(code, err + out).toBe(0); if (cmd[0] === python) { - expect(out).toBe("Frontend PTY: four cases passed\n"); + expect(out).toBe( + "Frontend PTY: eight lifetime and five selection cases passed\n" + ); } } } finally { @@ -131,3 +133,16 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( }, 240_000 ); + +test("finite exec tests explicitly opt out of the developer terminal", async () => { + for (const path of [ + "tests/native-project-exec.test.ts", + "tests/native-authored-project-exec.test.ts", + ]) { + const source = await readFile(path, "utf8"); + expect(source).toContain('process.env.HACK_NO_INTERACTIVE = "1"'); + expect(source).toContain( + 'restoreEnv("HACK_NO_INTERACTIVE", interactiveEnvironment)' + ); + } +}); diff --git a/tests/native-project-exec.test.ts b/tests/native-project-exec.test.ts index 6e85630d2..9ae2c4181 100644 --- a/tests/native-project-exec.test.ts +++ b/tests/native-project-exec.test.ts @@ -1,12 +1,20 @@ -import { afterEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, test } from "bun:test"; import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { nativeProjectExec } from "../src/backends/native-project-exec.ts"; import { saveNativeProjectRun } from "../src/backends/native-project-run.ts"; import { expectClosedRefusal } from "./helpers/closed-refusal.ts"; +import { restoreEnv } from "./helpers/env.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; +let interactiveEnvironment: string | undefined; +beforeEach(() => { + interactiveEnvironment = process.env.HACK_NO_INTERACTIVE; + process.env.HACK_NO_INTERACTIVE = "1"; +}); +afterEach(() => restoreEnv("HACK_NO_INTERACTIVE", interactiveEnvironment)); + const roots: string[] = []; afterEach(async () => { await Promise.all( From f7bd62f39a3ea17c5c3faf6bf1b230ecc65e21d9 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 18:39:13 +0000 Subject: [PATCH 10/13] fix(native): retain session identity across authored handoff failures --- docs/guides/native-candidate.md | 3 + .../src/provider/graph/native/foreground.rs | 32 +++-- .../provider/graph/native/foreground/tests.rs | 129 ++++++++++++++++++ 3 files changed, 152 insertions(+), 12 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 00c1a0223..22c4b6a2b 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -4117,6 +4117,9 @@ user's responsibility, as with Docker. Lost connections or processes before a known outcome, uncertain create/start dispatch and failed inspection retain a value-free receipt and `session_cleanup_uncertain` with the attempted session ID. No uncertain command is replayed. +If an authored owner disconnects during handoff or its reply cannot be validated, +Hack checks only the attempted session ID for retained evidence. A confirmed +refusal before the intent does not query unrelated sessions. | stdin | stdout | Foreground group | `HACK_NO_INTERACTIVE` | Selection | | --- | --- | --- | --- | --- | diff --git a/packages/runtime-core/src/provider/graph/native/foreground.rs b/packages/runtime-core/src/provider/graph/native/foreground.rs index 666b3d066..1db929d02 100644 --- a/packages/runtime-core/src/provider/graph/native/foreground.rs +++ b/packages/runtime-core/src/provider/graph/native/foreground.rs @@ -643,19 +643,27 @@ pub(in crate::provider) fn interactive( exec: Some(selected.clone()), one_off: None, }; - transport::write(&mut stream, &input, Duration::from_secs(5))?; + // Once dispatch begins, a transport or binding failure cannot prove that + // the owner stopped before its durable intent. Correlate only this attempt; + // a validated pre-intent refusal below remains unscoped. + let uncertain = |error: CandidateError| error.with_session(options.session); + transport::write(&mut stream, &input, Duration::from_secs(5)).map_err(uncertain)?; let descriptor = crate::provider::interactive_handoff::receive( &stream, std::time::Instant::now() + Duration::from_secs(40), - )?; - let reply: Reply = transport::read(&mut stream, Duration::from_secs(5), LIMIT)?; - reply.validate( - &expected, - Action::InteractiveExec, - None, - Some(&selected), - None, - )?; + ) + .map_err(uncertain)?; + let reply: Reply = + transport::read(&mut stream, Duration::from_secs(5), LIMIT).map_err(uncertain)?; + reply + .validate( + &expected, + Action::InteractiveExec, + None, + Some(&selected), + None, + ) + .map_err(uncertain)?; match reply.result { Outcome::Interactive { session, .. } if session.session == options.session => { let result = (|| { @@ -667,8 +675,8 @@ pub(in crate::provider) fn interactive( result.map_err(|_: CandidateError| CandidateError::new("session_cleanup_uncertain", "Interactive session handoff changed; the session was retained and no start was replayed.").with_session(&session.session)) } Outcome::InteractiveRefused { session } if session == options.session => Err(CandidateError::new("session_cleanup_uncertain", "Interactive admission ended after its durable intent; the session was retained and no start was replayed.").with_session(&session)), - Outcome::Refused { code, .. } => Err(refused().with_cause_code(code)), - _ => Err(refused()), + Outcome::Refused { code, .. } if descriptor.is_none() => Err(refused().with_cause_code(code)), + _ => Err(uncertain(refused())), } } fn request_selected( diff --git a/packages/runtime-core/src/provider/graph/native/foreground/tests.rs b/packages/runtime-core/src/provider/graph/native/foreground/tests.rs index 4fc48a4a0..198eca110 100644 --- a/packages/runtime-core/src/provider/graph/native/foreground/tests.rs +++ b/packages/runtime-core/src/provider/graph/native/foreground/tests.rs @@ -1044,6 +1044,7 @@ fn initial_console_size_is_required_only_for_interactive_owner_requests() { let wire = json!({ "version": 2, "kind": "native-graph-control", "run": RUN, "review": receipt.review.review_id(), "action": "interactive-exec", + "session": "12345678-abcd-abcd-abcd-123456789abc", "exec": {"service":"web", "argv":["stty", "size"], "workdir":null}, "console_size": [43, 91] }); @@ -1078,12 +1079,140 @@ fn initial_console_size_is_required_only_for_interactive_owner_requests() { .is_err() ); finite.as_object_mut().unwrap().remove("console_size"); + finite.as_object_mut().unwrap().remove("session"); serde_json::from_value::(finite) .unwrap() .validate(&receipt.review) .unwrap(); } +#[test] +fn interactive_handoff_failures_keep_only_the_attempted_session() { + use crate::provider::{ + graph::interactive_exec::Options, interactive_handoff, interactive_terminal::Leash, + }; + use std::os::unix::net::UnixStream; + + const SESSION: &str = "12345678-abcd-abcd-abcd-123456789abc"; + for case in [ + "disconnect-before-descriptor", + "disconnect-after-descriptor", + "malformed-reply", + "wrong-binding", + "wrong-session", + "retained-refusal", + "refusal-with-descriptor", + "pre-intent-refusal", + ] { + let fixture = Fixture::new(); + let receipt = fixture.receipt(); + let mut publication = + owner::Publication::bind(&fixture.candidate, &receipt.review).unwrap(); + journal::reserve(&fixture.candidate, &receipt).unwrap(); + let (input, mut frontend) = UnixStream::pair().unwrap(); + frontend.write_all(&[0; 4]).unwrap(); + let (leash, _) = Leash::receive(input.into()).unwrap(); + let argv = vec!["tool".into()]; + let options = Options { + session: SESSION, + run: RUN, + service: "web", + owner: &receipt.owner, + plan: receipt.review.review_id(), + namespace: receipt.review.scope().namespace, + native: true, + argv: &argv, + workdir: None, + expected_container: None, + expected_generation: None, + }; + let invalid = Options { + argv: &[], + ..options + }; + assert!( + interactive(&fixture.candidate, &invalid, [43, 91], &leash) + .err() + .unwrap() + .session() + .is_none() + ); + assert!(publication.accept().unwrap().is_none()); + std::thread::scope(|scope| { + let caller = + scope.spawn(|| interactive(&fixture.candidate, &options, [43, 91], &leash)); + let deadline = Instant::now() + Duration::from_secs(2); + let mut stream = loop { + if let Some(stream) = publication.accept().unwrap() { + break stream; + } + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(5)); + }; + let request: Request = + transport::read(&mut stream, Duration::from_secs(1), LIMIT).unwrap(); + request.validate(&receipt.review).unwrap(); + assert_eq!(request.session.as_deref(), Some(SESSION)); + if case != "disconnect-before-descriptor" { + let (attached, _engine) = UnixStream::pair().unwrap(); + let descriptor = matches!( + case, + "disconnect-after-descriptor" | "refusal-with-descriptor" + ) + .then_some(attached); + interactive_handoff::send(&stream, descriptor, deadline).unwrap(); + if case == "malformed-reply" { + transport::write( + &mut stream, + &json!({"unexpected": true}), + Duration::from_secs(1), + ) + .unwrap(); + } else if case != "disconnect-after-descriptor" { + let result = match case { + "wrong-session" => Outcome::InteractiveRefused { + session: "87654321-abcd-abcd-abcd-123456789abc".into(), + }, + "retained-refusal" => Outcome::InteractiveRefused { + session: SESSION.into(), + }, + _ => Outcome::Refused { + code: "native_graph_foreground".into(), + stop_failures: None, + }, + }; + let reply = Reply { + version: 2, + kind: ReplyKind::NativeGraphControlReply, + run: if case == "wrong-binding" { + "a".repeat(32) + } else { + RUN.into() + }, + review: receipt.review.review_id().into(), + result, + }; + transport::write(&mut stream, &reply, Duration::from_secs(1)).unwrap(); + } + } + drop(stream); + let error = caller.join().unwrap().err().unwrap(); + assert_eq!( + error.session(), + if case == "pre-intent-refusal" { + None + } else { + Some(SESSION) + }, + "{case}" + ); + // A failure never retries the authenticated request. + assert!(publication.accept().unwrap().is_none()); + }); + publication.finish().unwrap(); + } +} + #[test] fn finite_exec_wire_authenticates_owner_member_and_binary_nonzero_completion() { let fixture = Fixture::new(); From 6bf17ae471306c57fe145d21ace8369b6309105b Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 18:47:17 +0000 Subject: [PATCH 11/13] test(native): budget retained-file setup before deadline refusals --- tests/native-compose-adoption-file-generation.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/native-compose-adoption-file-generation.test.ts b/tests/native-compose-adoption-file-generation.test.ts index 6b48c1756..a16e87341 100644 --- a/tests/native-compose-adoption-file-generation.test.ts +++ b/tests/native-compose-adoption-file-generation.test.ts @@ -813,6 +813,9 @@ test.each([ } }, 30_000); +// Preparing the real retained store performs bounded child probes before the +// invalid-deadline assertions. Give setup the same outer budget as adjacent +// prepared-store tests; the rejected mutation deadlines remain unchanged. test("HACK-1359: file8 rejects missing and non-finite mutation deadlines without effects", async () => { const { store, generation } = await prepared(); try { @@ -828,4 +831,4 @@ test("HACK-1359: file8 rejects missing and non-finite mutation deadlines without } finally { await store.close(); } -}); +}, 30_000); From 11b6ae7d425fe27181f9bb9c178accaa3aa1b7c0 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:14:08 +0000 Subject: [PATCH 12/13] fix(native): restore terminals before quiet session retirement --- docs/guides/native-candidate.md | 8 +- docs/reference/v5-model-coverage.md | 2 +- .../src/provider/graph/interactive_exec.rs | 43 +++-- .../runtime-core/src/provider/graph/mod.rs | 2 +- .../src/provider/graph/native/runtime.rs | 2 +- .../src/provider/interactive_session.rs | 147 ++++++++++++++++-- src/backends/native-interactive-exec.ts | 4 +- tests/fixtures/native-interactive-frontend.py | 14 +- tests/fixtures/native-interactive-runtime.py | 9 +- tests/native-interactive-exec.test.ts | 2 +- 10 files changed, 191 insertions(+), 42 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 22c4b6a2b..7a65a6608 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -4112,7 +4112,9 @@ service: a peer service with no managed values still uses ordinary interactive e Finite exec retains its existing behavior. Normal stream closure plus a validated Engine exit returns the exit code and -silently retires the session receipt. Surviving background processes are the +restores the terminal before attempting to retire the session receipt. A busy or +failed receipt store preserves that known exit code silently and leaves the receipt +for later reconciliation. Surviving background processes are the user's responsibility, as with Docker. Lost connections or processes before a known outcome, uncertain create/start dispatch and failed inspection retain a value-free receipt and `session_cleanup_uncertain` with the attempted session ID. @@ -4137,7 +4139,9 @@ proven stopped-boot evidence; intents without an exec ID require one of the latter two proofs. It reports the exact remaining count and IDs after its bounded pass. Use `hack down` for an affected project if uncertainty remains. A new boot alone is not proof. Ordinary successful exec does not consume the 1024 retained -session limit. +session limit when retirement succeeds. `hack down` names remaining receipts only +for the selected run; receipts for other runs are reported as a count without +their service names or run/session IDs. ### Interactive exec acceptance diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 4390215cc..2e0a25779 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -36,7 +36,7 @@ must be attached to the eventual exact integration revision. | Property and critical states | Model coverage | Implementation/regression boundary | Remaining qualification | | --- | --- | --- | --- | -| Interactive exec write-ahead admission, crash, EOF, replacement boot and retirement | interactive-session (459 states); no-replay, EOF-retirement, failed-inspect and wrong-boot negative controls | `interactive_session.rs` atomic intent/exec records, exact stopped-boot proof, known-undispatched withdrawal and lock-free observation/revalidation tests; Engine effect fences; authored owner start and SCM_RIGHTS handoff | One session, two boot identities, two possible starts. Atomic durable publication and exact identity/absence proofs are assumptions; real fsync/power loss, real owner/Engine handoff and VM loss need Mac acceptance. Validated exact exec exit permits retirement; it makes no descendant cleanup claim. | +| Interactive exec write-ahead admission, crash, EOF, replacement boot and retirement | interactive-session (459 states); no-replay, EOF-retirement, failed-inspect and wrong-boot negative controls | `interactive_session.rs` atomic intent/exec records, exact stopped-boot proof, known-undispatched withdrawal, restore-before-busy-retirement and run-scoped cleanup-report tests, and lock-free observation/revalidation tests; Engine effect fences; authored owner start and SCM_RIGHTS handoff | One session, two boot identities, two possible starts. Atomic durable publication and exact identity/absence proofs are assumptions; real fsync/power loss, real owner/Engine handoff and VM loss need Mac acceptance; receipt lock timing and terminal restoration are regression-covered, not modeled. Validated exact exec exit permits retirement; it makes no descendant cleanup claim. | | Private interactive envelope, echo state and bytes still in flight on failure | interactive-envelope (15 states); restore-on-error negative control | HKEE2 launcher and host gate, Linux PTY seven-case matrix, bounded marker/ACK and coalesced-output tests | Two abstract private bytes, one handshake. PTY fixtures prove local termios ordering; Docker detach scanning, real guest wiring, same-user access and VM effects remain separate. Source/compiled Bun PTY and native-process fixtures cover lifetime/terminal boundaries, not model liveness. | | Guest control ready before exposed bridge sockets bind; identity changes or the startup deadline expires | No new TLA+ model; deterministic clock schedules both readiness orders against real Unix socket nodes | `provider/bridge.rs` waits only in fresh `finish_boot`, before adoption, and keeps running audits immediate. `bridge_startup_tests.rs` covers two slots, late creation and mode tightening, permanent absence/unsafe modes, type/UID/link refusals, provider loss before/after observation and late success rejection. No wait mutates sockets or grants cleanup authority; the existing verified-provider stop handles failure. `lifecycle/boot_sequence_tests.rs` drives the real waiter through the production fresh-boot sequence and rejects audit/adoption on timeout, unsafe sockets or provider loss. `native-runtime-client.test.ts` checks the fixed runtime recover command and identical-root hint reach the frontend | Two sockets, finite scheduled events, injected provider verification and monotonic clock. Kernel process identity, provider scheduling, disk durability and successful recovery remain host boundaries. Require ten consecutive real-VM Mac Ctrl-C scenarios with pool stop/start; portable tests are not that evidence. | | CI cancellation must not lose merged-change coverage; only exact-head full evidence may qualify a prerelease | No maintained GitHub scheduler model. Finite fixture interleavings cover predecessor success/failure/cancellation, two competing full/scoped runs and two rerun attempts | `ci.yml` serializes next pushes in one non-cancelling group (GitHub cancels only the replaced pending run), cancels stale PR runs, and requires a successful before run for selection. `selectPrereleaseCiRun` reads current attempts; `requireBranchChecks` binds suite and job check IDs. `ci-trusted-policy`, `prerelease-plan` and `release-prepare-gate` regressions reject cancelled predecessors, green subsets, stale checks, skipped hosts and changed attempts | API snapshots and GitHub group/event semantics are assumptions; races after the last read remain possible. Publication rechecks the frozen protected head and full evidence but does not transactionally lock GitHub. Hosted dispatch/cancellation checks qualify wiring; no scheduler liveness or formal refinement claim. | diff --git a/packages/runtime-core/src/provider/graph/interactive_exec.rs b/packages/runtime-core/src/provider/graph/interactive_exec.rs index 1ea55cb3f..eaaa780e1 100644 --- a/packages/runtime-core/src/provider/graph/interactive_exec.rs +++ b/packages/runtime-core/src/provider/graph/interactive_exec.rs @@ -65,6 +65,24 @@ pub struct Completion { pub session: String, pub retained: bool, } + +/// Restore before any potentially blocking receipt mutation. A validated exit +/// remains a completion even if bookkeeping must be retried by reconciliation. +#[cfg(any(target_os = "macos", test))] +pub(in crate::provider) fn finish( + session: &str, + result: Result, + restore: impl FnOnce() -> Result<(), CandidateError>, + retire: impl FnOnce() -> Result<(), CandidateError>, +) -> Result { + restore().map_err(|error| error.with_session(session))?; + let exit_code = result.map_err(|error| error.with_session(session))?; + Ok(Completion { + exit_code, + session: session.into(), + retained: retire().is_err(), + }) +} pub fn execute( candidate: &Candidate, options: &Options<'_>, @@ -167,7 +185,7 @@ fn native( }; let mut attached = admission?; let receipt = &attached.receipt; - let result: Result<(i32, bool), CandidateError> = (|| { + let result: Result = (|| { leash.check()?; if let Some(managed) = &managed { interactive_exec::deliver( @@ -189,21 +207,14 @@ fn native( }) }, )?; - let exit = observer.interactive_exit(receipt)?; - // A known command status survives bookkeeping failures. Retained - // evidence is reported separately and can be reconciled with this proof. - let retained = interactive_session::retire_completed(candidate, receipt).is_err(); - Ok((exit, retained)) + observer.interactive_exit(receipt) })(); - terminal - .restore() - .map_err(|error| error.with_session(&receipt.session))?; + let completion = finish( + &receipt.session, + result, + || terminal.restore(), + || interactive_session::retire_completed(candidate, receipt), + ); drop(attached.stream); - result - .map(|(exit_code, retained)| Completion { - exit_code, - session: receipt.session.clone(), - retained, - }) - .map_err(|error| error.with_session(&receipt.session)) + completion } diff --git a/packages/runtime-core/src/provider/graph/mod.rs b/packages/runtime-core/src/provider/graph/mod.rs index 7127d0bbc..e77f385cf 100644 --- a/packages/runtime-core/src/provider/graph/mod.rs +++ b/packages/runtime-core/src/provider/graph/mod.rs @@ -2245,7 +2245,7 @@ fn cleanup_owned_fenced( state::write(&root.join("state.json"), &receipt)?; fence()?; #[cfg(target_os = "macos")] - crate::provider::interactive_session::after_cleanup(candidate); + crate::provider::interactive_session::after_cleanup(candidate, &receipt.run); Ok(receipt) } diff --git a/packages/runtime-core/src/provider/graph/native/runtime.rs b/packages/runtime-core/src/provider/graph/native/runtime.rs index a0d39cd43..55af2b643 100644 --- a/packages/runtime-core/src/provider/graph/native/runtime.rs +++ b/packages/runtime-core/src/provider/graph/native/runtime.rs @@ -1501,7 +1501,7 @@ fn cleanup_inner( check_startup(guard)?; } #[cfg(target_os = "macos")] - crate::provider::interactive_session::after_cleanup(candidate); + crate::provider::interactive_session::after_cleanup(candidate, receipt.review.scope().run); Ok(receipt) } #[cfg(test)] diff --git a/packages/runtime-core/src/provider/interactive_session.rs b/packages/runtime-core/src/provider/interactive_session.rs index 9bb792ad9..cd492d744 100644 --- a/packages/runtime-core/src/provider/interactive_session.rs +++ b/packages/runtime-core/src/provider/interactive_session.rs @@ -584,26 +584,43 @@ pub fn reconcile_observed(candidate: &Candidate) -> Result (), Ok(records) => { - cleanup_notice(format_args!( - "session_cleanup_uncertain: {} interactive session receipts remain in the native home; run hack exec-sessions --reconcile, or hack down for their projects.", - records.len() - )); - for record in records { - cleanup_notice(format_args!( - "session {} run {} service {}", - record.session, record.binding.run, record.binding.service - )); + for line in cleanup_report(&records, run) { + cleanup_notice(format_args!("{line}")); } } Err(_) => cleanup_warning(), } } +#[cfg(any(target_os = "macos", test))] +fn cleanup_report(records: &[Receipt], run: &str) -> Vec { + let selected: Vec<_> = records.iter().filter(|r| r.binding.run == run).collect(); + let mut lines = Vec::new(); + if !selected.is_empty() { + lines.push(format!( + "session_cleanup_uncertain: {} interactive session receipts remain for this run; run hack exec-sessions --reconcile, or retry hack down for this project and instance.", + selected.len() + )); + for record in &selected { + lines.push(format!( + "session {} run {} service {}", + record.session, record.binding.run, record.binding.service + )); + } + } + let other = records.len() - selected.len(); + if other > 0 { + lines.push(format!( + "{other} interactive session receipts belong to other runs; use hack exec-sessions to inspect them." + )); + } + lines +} + /// Caller supplies exact absence or established stopped-boot proof for each /// binding. Both must cover its boot and owner, never a new-boot 404. This helper /// performs no Engine effects and does not turn malformed receipts into absence. @@ -722,6 +739,114 @@ mod tests { assert!(retained(&candidate).unwrap().is_empty()); fs::remove_dir_all(root).unwrap(); } + #[test] + fn terminal_restores_before_busy_retirement_and_preserves_known_exit() { + use super::super::graph::interactive_exec::finish; + let (root, candidate, binding) = fixture(); + let receipt = admit(&candidate, binding) + .unwrap() + .record_exec(&candidate, &"e".repeat(64)) + .unwrap(); + let locked = Store::open(&candidate, false).unwrap().unwrap(); + for exit in [0, 7] { + let restored = std::cell::Cell::new(false); + let completion = finish( + &receipt.session, + Ok(exit), + || { + restored.set(true); + Ok(()) + }, + || { + assert!(restored.get(), "store wait began with a raw terminal"); + let error = retire_completed(&candidate, &receipt).unwrap_err(); + assert_eq!(error.code, "session_receipt_busy"); + Err(error) + }, + ) + .unwrap(); + assert_eq!(completion.exit_code, exit); + assert!(completion.retained); + assert_eq!(retained(&candidate).unwrap(), vec![receipt.clone()]); + } + drop(locked); + retire_completed(&candidate, &receipt).unwrap(); + assert!(retained(&candidate).unwrap().is_empty()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn failed_restore_and_unknown_exit_stay_scoped_and_never_retire() { + use super::super::graph::interactive_exec::finish; + let session = "00000000-0000-4000-8000-000000000001"; + let error = finish( + session, + Ok(0), + || { + Err(CandidateError::new( + "interactive_terminal_restore_failed", + "restore", + )) + }, + || panic!("failed restoration must preserve the receipt"), + ) + .err() + .unwrap(); + assert_eq!(error.code, "interactive_terminal_restore_failed"); + assert_eq!(error.session(), Some(session)); + let restored = std::cell::Cell::new(false); + let error = finish( + session, + Err(CandidateError::new("session_cleanup_uncertain", "unknown")), + || { + restored.set(true); + Ok(()) + }, + || panic!("unknown exit is not retirement proof"), + ) + .err() + .unwrap(); + assert!(restored.get()); + assert_eq!(error.code, "session_cleanup_uncertain"); + assert_eq!(error.session(), Some(session)); + } + + #[test] + fn cleanup_names_only_the_selected_run_and_counts_unrelated_receipts() { + let (root, candidate, binding) = fixture(); + let selected = admit(&candidate, binding.clone()).unwrap(); + let mut unrelated = binding.clone(); + unrelated.run = "f".repeat(32); + unrelated.namespace = "e".repeat(64); + unrelated.service = "unrelated-private-service".into(); + let other = admit(&candidate, unrelated).unwrap(); + let records = retained(&candidate).unwrap(); + let report = cleanup_report(&records, &binding.run).join("\n"); + assert!(report.contains(&selected.receipt.session)); + assert!(report.contains(&binding.run)); + assert!(report.contains("service web")); + assert!(report.contains("1 interactive session receipts belong to other runs")); + for value in [ + &other.receipt.session, + &other.receipt.binding.run, + &other.receipt.binding.namespace, + &other.receipt.binding.service, + ] { + assert!(!report.contains(value)); + } + retire_completed( + &candidate, + &selected.record_exec(&candidate, &"e".repeat(64)).unwrap(), + ) + .unwrap(); + let report = cleanup_report(&retained(&candidate).unwrap(), &binding.run).join("\n"); + assert!(!report.contains("session_cleanup_uncertain")); + assert!(!report.contains(&other.receipt.session)); + assert!(report.contains("hack exec-sessions")); + assert_eq!(retained(&candidate).unwrap(), vec![other.receipt]); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn completed_receipt_retirement_is_exact_and_does_not_accumulate() { let (root, candidate, binding) = fixture(); diff --git a/src/backends/native-interactive-exec.ts b/src/backends/native-interactive-exec.ts index 4b982dbbd..5266931d3 100644 --- a/src/backends/native-interactive-exec.ts +++ b/src/backends/native-interactive-exec.ts @@ -388,7 +388,9 @@ async function finishInteractiveReport({ await reportRetainedSessions(opts, check, remedy, session); return exit === 0 ? 1 : exit; } - if (report.retained) { + // A completed report proves the exit. Bookkeeping may retain its receipt for + // later reconciliation without turning that known result into a warning. + if (report.retained && report.failure) { await reportRetainedSessions(opts, check, remedy, session); } if (report.failure) { diff --git a/tests/fixtures/native-interactive-frontend.py b/tests/fixtures/native-interactive-frontend.py index 2e9881dde..60396ba7e 100644 --- a/tests/fixtures/native-interactive-frontend.py +++ b/tests/fixtures/native-interactive-frontend.py @@ -91,7 +91,7 @@ def record(control, master, output, expected, timeout, mode): protected_keys = json.loads(sys.argv[2]) command = sys.argv[3:] assert all(os.environ.get(key) == "synthetic-parent-key-canary" for key in protected_keys) -for mode in ("normal", "child-kill", "frontend-kill", "cancel", "refused", "old-launcher", "retained", "cleared"): +for mode in ("normal", "child-kill", "frontend-kill", "cancel", "refused", "old-launcher", "retained", "retained-zero", "cleared"): for name in ("reader.pid", "reader.closed", "session.queried", "session.cleared"): try: os.unlink(os.path.join(root, name)) @@ -131,8 +131,10 @@ def session(): elif mode == "cancel": os.kill(frontend, signal.SIGTERM) code = record(control, master, output, b"E", 10, mode) - if mode in ("normal", "retained", "cleared"): + if mode in ("normal", "retained"): assert code == 7, code + elif mode == "retained-zero": + assert code == 0, code else: assert code != 0, code if mode == "frontend-kill": @@ -163,15 +165,17 @@ def session(): output.extend(os.read(master, 8192)) assert b"synthetic-private-envelope-canary" not in output assert b"synthetic-parent-key-canary" not in output - if mode in ("normal", "refused", "old-launcher"): + if mode in ("normal", "refused", "old-launcher", "retained", "retained-zero"): assert not os.path.exists(os.path.join(root, "session.queried")) if mode == "old-launcher": assert b"HACK_NO_INTERACTIVE=1" in output and b"restart the service" in output assert b"session_cleanup_uncertain" not in output, repr(output) + if mode in ("normal", "retained", "retained-zero"): + assert output == b"child-ready\n", repr(output) elif mode == "cleared": assert b"No receipt is currently retained for session" in output assert b"without a result" not in output - assert b"session_cleanup_uncertain" not in output + assert b"session_cleanup_uncertain: session " not in output elif mode != "frontend-kill": with open(os.path.join(root, "session.id")) as file: session_id = file.read().encode() @@ -238,4 +242,4 @@ def session(): control.close() os.close(master) os.close(slave) -print("Frontend PTY: eight lifetime and five selection cases passed") +print("Frontend PTY: nine lifetime and five selection cases passed") diff --git a/tests/fixtures/native-interactive-runtime.py b/tests/fixtures/native-interactive-runtime.py index 2f10d0548..2b10aa74a 100644 --- a/tests/fixtures/native-interactive-runtime.py +++ b/tests/fixtures/native-interactive-runtime.py @@ -47,11 +47,14 @@ if mode == "cleared": with open("session.cleared", "w") as file: file.write("cleared") -if mode in ("normal", "retained", "cleared"): + control.sendall(json.dumps({"version": 1, "outcome": "refused", "code": "session_cleanup_uncertain", "session": session}).encode() + b"\n") + sys.exit(1) +if mode in ("normal", "retained", "retained-zero"): + exit_code = 0 if mode == "retained-zero" else 7 control.sendall(json.dumps({"version": 1, "outcome": "completed", "completion": { - "session": session, "exit_code": 7, "retained": mode != "normal"}}).encode() + b"\n") + "session": session, "exit_code": exit_code, "retained": mode != "normal"}}).encode() + b"\n") # Deliberately leave raw mode: the frontend's independent snapshot owns it. - sys.exit(7) + sys.exit(exit_code) assert select.select([control], [], [], 10)[0], "leash deadline" assert control.recv(1) == b"", "unexpected post-envelope data" previous = signal.signal(signal.SIGTTOU, signal.SIG_IGN) diff --git a/tests/native-interactive-exec.test.ts b/tests/native-interactive-exec.test.ts index bb257fb52..7330f21ab 100644 --- a/tests/native-interactive-exec.test.ts +++ b/tests/native-interactive-exec.test.ts @@ -123,7 +123,7 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( expect(code, err + out).toBe(0); if (cmd[0] === python) { expect(out).toBe( - "Frontend PTY: eight lifetime and five selection cases passed\n" + "Frontend PTY: nine lifetime and five selection cases passed\n" ); } } From f507a5b9535612530ed2df798dd3f14bfa250bc8 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:48:55 +0000 Subject: [PATCH 13/13] style(native): order merged engine modules --- packages/runtime-core/src/provider/engine.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime-core/src/provider/engine.rs b/packages/runtime-core/src/provider/engine.rs index 49ad96e82..69e8e983f 100644 --- a/packages/runtime-core/src/provider/engine.rs +++ b/packages/runtime-core/src/provider/engine.rs @@ -12,9 +12,9 @@ use std::io::Read; use std::path::Path; use std::time::{Duration, Instant}; -pub(in crate::provider) mod inspection; #[cfg(any(target_os = "macos", test))] mod exec_io; +pub(in crate::provider) mod inspection; #[cfg(any(target_os = "macos", test))] pub(in crate::provider) mod interactive_exec; #[cfg(any(target_os = "macos", test))]