diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef102ebb6..9579d8a32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -421,6 +421,20 @@ 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 + run: | + 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 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..d51e57dd8 --- /dev/null +++ b/docs/diagrams/interactive-session.mmd @@ -0,0 +1,37 @@ +--- +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 + 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 new file mode 100644 index 000000000..fa7e2f03c --- /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 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 f5bdedb02..a875ff67e 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -4089,8 +4089,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 @@ -4106,6 +4107,86 @@ 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 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 +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, 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. + +Interactive managed-env exec refuses if fresh delivery is unavailable; it does +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. + +Normal stream closure plus a validated Engine exit returns the exit code and +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. +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 | +| --- | --- | --- | --- | --- | +| 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 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 + +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; 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 + 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. +- 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. + 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/guides/v5-release-notes.md b/docs/guides/v5-release-notes.md index 12bc8c11f..1a68266b9 100644 --- a/docs/guides/v5-release-notes.md +++ b/docs/guides/v5-release-notes.md @@ -239,6 +239,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/docs/reference/cli.md b/docs/reference/cli.md index 46880255c..ad34fa58f 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 2d1acf9fd..bb66e90dc 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 | @@ -551,8 +551,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, @@ -573,6 +573,74 @@ 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 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, +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 **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 +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 +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. 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. +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 +[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 431309c73..e9c611693 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 (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. | | Native status across startup lease stages; failed start with running services (HACK-2110) | [native-status-start](../../tests/models/tla/native-status-start/COVERAGE.md) | Observer-only presentation, stable journal fence, distinct authored Observe/Status replies; real-flock held-reader start test and expiry diagnostic; plain-down exact-proof recovery tests. Failure capture occurs at startup/effect verification and owned cleanup, never status; first failure wins, incomplete-rebind capture and owner-crash reconstruction are excluded | Seven acquisition stages, one reader, one start; 110 states. Negative control fails after services start. Second-mutator refusal and complete recovery proof are assumptions checked separately in code, not independently proved by this model. No new control-loop tick or lease; cleanup settle is bounded at ten seconds per exited listener. Lead Mac gate: ps every 0.5 s across ten starts, cross-project polling and failed-start plain down. | | 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/guest/environment-launcher.zig b/packages/runtime-core/guest/environment-launcher.zig index 64f0008db..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,23 +119,131 @@ 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. 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(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.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 }}; + 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) { @@ -126,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; @@ -156,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); @@ -165,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); +} diff --git a/packages/runtime-core/src/error.rs b/packages/runtime-core/src/error.rs index 1272750b5..96f4e4bb5 100644 --- a/packages/runtime-core/src/error.rs +++ b/packages/runtime-core/src/error.rs @@ -144,6 +144,9 @@ pub struct RouteProbeDiagnostic { /// `process_refusals`, `plan_change` and `route_probe`. #[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")] @@ -201,6 +204,24 @@ pub struct CandidateError { } impl CandidateError { + pub fn with_session(mut self, session: &str) -> Self { + // 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 + } + 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..0c3cb0c1a --- /dev/null +++ b/packages/runtime-core/src/interactive_exec_cli.rs @@ -0,0 +1,171 @@ +//! 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 ![ + "--session-id", + "--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 { + session: get("--session-id")?, + 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, 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")] + { + 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| { + 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":records,"reconciled":reconciled}) + ); + Ok(()) + } + #[cfg(not(target_os = "macos"))] + { + 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 09f9cc1d6..8968c521a 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,13 @@ 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", arguments @ ..] => { + interactive_exec_cli::sessions(&discover_candidate(&requested)?, arguments)?; + } ["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 63c4b0447..69e8e983f 100644 --- a/packages/runtime-core/src/provider/engine.rs +++ b/packages/runtime-core/src/provider/engine.rs @@ -12,8 +12,12 @@ use std::io::Read; use std::path::Path; use std::time::{Duration, Instant}; +#[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))] pub(in crate::provider) mod log_follow; #[cfg(any(all(target_os = "macos", feature = "environment-launcher"), test))] mod private_service_exec; @@ -539,8 +543,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, @@ -664,6 +668,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/exec_io.rs b/packages/runtime-core/src/provider/engine/exec_io.rs new file mode 100644 index 000000000..ca609db1d --- /dev/null +++ b/packages/runtime-core/src/provider/engine/exec_io.rs @@ -0,0 +1,184 @@ +//! 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 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)?; + // 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( + stream.as_raw_fd(), + bytes.as_ptr().cast(), + bytes.len(), + libc::MSG_DONTWAIT | libc::MSG_NOSIGNAL, + ) + }; + 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 { + 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; 87384], 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 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(); + 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 new file mode 100644 index 000000000..df838cabd --- /dev/null +++ b/packages/runtime-core/src/provider/engine/interactive_exec.rs @@ -0,0 +1,500 @@ +//! 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, + 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")] +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], +} + +#[cfg(target_os = "macos")] +impl Engine<'_> { + pub(in crate::provider) fn interactive_start( + &self, + candidate: &Candidate, + binding: Binding, + command: Command<'_>, + deadline: Instant, + fresh: &dyn Fn() -> Result<(), CandidateError>, + ) -> Result { + if self.cleanup_only || !binding.valid() || command.console_size.contains(&0) { + return Err(uncertain()); + } + fresh()?; + self.guest.verify()?; + self.guest.before_effect()?; + 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; + 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,"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)?)?; + 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, Some(command.console_size))?; + 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()?; + match super::exec_io::write(stream, bytes, deadline) { + Ok(0) => return Err(uncertain()), + Ok(n) => bytes = &bytes[n..], + Err(e) if super::exec_io::retry(&e) => {} + 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()); + } + let mut byte = [0]; + match super::exec_io::read(stream, &mut byte, deadline) { + Ok(0) => return Err(uncertain()), + Ok(_) => { + bytes.push(byte[0]); + if byte[0] == b'\n' { + return Ok(bytes); + } + } + Err(e) if super::exec_io::retry(&e) => {} + 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_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); + let exit = wait_exit(deadline, &mut || { + self.interactive_binding(receipt, deadline) + })?; + self.guest.verify()?; + Ok(exit) + } +} + +fn wait_exit( + deadline: Instant, + inspect: &mut dyn FnMut() -> Result, +) -> Result { + loop { + remaining(deadline)?; + let value = inspect()?; + remaining(deadline)?; + 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::*; + use std::io::{Read, Write}; + #[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 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"] { + 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 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 [ + 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..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()) @@ -122,27 +123,45 @@ pub(super) fn start( id: &str, deadline: Instant, ) -> Result<(), CandidateError> { - let body = r#"{"Detach":false,"Tty":false}"#; + start_mode(stream, id, deadline, None) +} +pub(super) fn start_mode( + stream: &mut UnixStream, + id: &str, + deadline: Instant, + console_size: Option<[u16; 2]>, +) -> Result<(), CandidateError> { + 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}", 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())?; @@ -209,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()); @@ -238,6 +258,46 @@ mod tests { bytes } #[test] + 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,"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), + Some([43, 91]), + ) + .unwrap(); + let mut output = Vec::new(); + client.read_to_end(&mut output).unwrap(); + assert_eq!(output, b"first-output"); + 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 || { @@ -245,6 +305,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/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..eaaa780e1 --- /dev/null +++ b/packages/runtime-core/src/provider/graph/interactive_exec.rs @@ -0,0 +1,220 @@ +//! Exact interactive admission followed by a lease-free terminal lifetime. +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, + 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 !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) + || 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, +} + +/// 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<'_>, + 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); + // 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, console_size, &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), + }, + console_size, + options.session, + managed.as_ref(), + &|| leash.check(), + ) + }; + let mut attached = admission?; + let receipt = &attached.receipt; + let result: 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) + })(); + let completion = finish( + &receipt.session, + result, + || terminal.restore(), + || interactive_session::retire_completed(candidate, receipt), + ); + drop(attached.stream); + completion +} diff --git a/packages/runtime-core/src/provider/graph/mod.rs b/packages/runtime-core/src/provider/graph/mod.rs index b2760947b..95a555d5e 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; #[cfg(any(target_os = "macos", test))] pub mod status; @@ -2247,6 +2248,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, &receipt.run); 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 055cc67f2..7ee1b35cf 100644 --- a/packages/runtime-core/src/provider/graph/native/foreground.rs +++ b/packages/runtime-core/src/provider/graph/native/foreground.rs @@ -33,6 +33,7 @@ pub enum Action { Cleanup, Logs, Exec, + InteractiveExec, Run, } #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -71,15 +72,30 @@ 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]>, + #[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, + } || 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::Observe | Action::Cleanup, None, None, None) => false, _ => true, @@ -107,6 +123,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, }, @@ -168,6 +191,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()); @@ -367,7 +421,39 @@ 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, + request.console_size.ok_or_else(refused)?, + request.session.as_deref().ok_or_else(refused)?, + &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::Observe => { runtime::status(candidate, &run).map(|snapshot| Outcome::Observed { snapshot }) @@ -436,6 +522,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)); } } @@ -449,7 +549,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) @@ -507,6 +610,86 @@ 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, + 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(), + 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, + console_size: Some(console_size), + session: Some(options.session.into()), + logs: None, + exec: Some(selected.clone()), + one_off: None, + }; + // 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), + ) + .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 = (|| { + 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 } 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, .. } if descriptor.is_none() => Err(refused().with_cause_code(code)), + _ => Err(uncertain(refused())), + } +} fn request_selected( candidate: &Candidate, run: &str, @@ -525,6 +708,8 @@ fn request_selected( run: run.into(), 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/foreground/tests.rs b/packages/runtime-core/src/provider/graph/native/foreground/tests.rs index effcb5d62..227ad8e1b 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,182 @@ 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", + "session": "12345678-abcd-abcd-abcd-123456789abc", + "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"); + 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(); diff --git a/packages/runtime-core/src/provider/graph/native/runtime.rs b/packages/runtime-core/src/provider/graph/native/runtime.rs index 99781b161..f3d0ceb87 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")] @@ -1541,6 +1543,8 @@ fn cleanup_inner( finish(&observed)?; check_startup(guard)?; } + #[cfg(target_os = "macos")] + crate::provider::interactive_session::after_cleanup(candidate, receipt.review.scope().run); 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..59e581140 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,79 @@ 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, + console_size: [u16; 2], + session: &str, + 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(), + }, + crate::provider::engine::interactive_exec::Command { + 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 1a0702a5a..dbe862179 100644 --- a/packages/runtime-core/src/provider/graph/service_exec.rs +++ b/packages/runtime-core/src/provider/graph/service_exec.rs @@ -67,6 +67,128 @@ fn validate(options: &ServiceExecOptions<'_>) -> Result<(), CandidateError> { Ok(()) } +#[cfg(target_os = "macos")] +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 { + 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)?; + 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 mounted_slot = interactive_environment_slot(&container, managed.is_some())?; + let slot = if receipt.environment_attached { + crate::provider::environment_recovery::active_exec_slot( + candidate, + engine.guest(), + &receipt.run, + options.service, + &resource.name, + mounted_slot.as_deref(), + )? + } else { + None + }; + 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()).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(), + ]; + 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(), + }, + crate::provider::engine::interactive_exec::Command { + argv: &argv, + workdir: options.workdir, + console_size, + session, + }, + 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, @@ -225,6 +347,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. Use HACK_NO_INTERACTIVE=1 hack exec for finite exec, or restart the service with the current candidate.", + )); + } + 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"] @@ -385,6 +525,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-{}", 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..26a5f3850 --- /dev/null +++ b/packages/runtime-core/src/provider/interactive_handoff.rs @@ -0,0 +1,491 @@ +//! 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()); + } +} + +/// 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, +) -> 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()); + } + // 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. + 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(); + 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(); + 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); + 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(); + engine + .set_read_timeout(Some(Duration::from_secs(1))) + .unwrap(); + assert_eq!( + send(&sender, Some(session), deadline).unwrap_err().code, + "session_cleanup_uncertain" + ); + 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(); + 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(), + 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); + 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(), + 0, + "all received rights must close" + ); + } + } + #[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(); + 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..cd492d744 --- /dev/null +++ b/packages/runtime-core/src/provider/interactive_session.rs @@ -0,0 +1,1082 @@ +//! Value-free write-ahead interactive intents. They authorize no replay. Even a +//! 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}, + os::unix::fs::MetadataExt, + 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", + "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: Option, +} +#[derive(Clone, Debug, 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 + } +} +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 { + 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: Some(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> { + 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()); + } + 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 { + 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(); + 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 = self.read_record(&entry.path())?; + 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(|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() + || 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 = self.read_record(&entry.path())?; + 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, + session: &str, +) -> Result { + if !binding.valid() || !environment_recovery::uuid(session) { + 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. List with hack exec-sessions; try hack exec-sessions --reconcile to clear proven exits, or hack down for the affected project.", + )); + } + let receipt = Receipt { + version: 1, + session: session.into(), + binding, + exec: None, + }; + store.write(&receipt)?; + Ok(Intent { receipt }) +} +pub fn retained(candidate: &Candidate) -> Result, CandidateError> { + 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, +} + +/// 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() { + 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." + )); +} + +#[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 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, &|receipt| { + let binding = &receipt.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(_) 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), + } + }) +} + +/// Bookkeeping never prevents the container cleanup that resolves uncertainty. +#[cfg(target_os = "macos")] +pub(crate) fn after_cleanup(candidate: &Candidate, run: &str) { + let _ = reconcile_observed(candidate); + match retained(candidate) { + Ok(records) => { + 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. +pub(crate) fn reconcile( + candidate: &Candidate, + proof: &dyn Fn(&Receipt) -> 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)?, 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.retire(&receipt)?; + 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 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(); + 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 alone is not proof. + assert_eq!( + reconcile(&candidate, &|b| { + assert_eq!(&b.binding, &binding); + Ok(RetirementProof::ContainerAbsent) + }) + .unwrap(), + 2 + ); + 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(); + 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(); + 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..7e556eae8 --- /dev/null +++ b/packages/runtime-core/src/provider/interactive_terminal.rs @@ -0,0 +1,638 @@ +//! 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, + libc::SIGTTIN, + libc::SIGTTOU, + ] { + // 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(( + 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 { + // 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( + "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)?; + } + } + } +} + +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(); + } +} +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(); + 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(); + 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(); + let mut ack = [0]; + finish.read_exact(&mut ack).unwrap(); + assert_eq!(&ack, b"F"); + } + + #[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, signal and background 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 71c6e14b2..b445634a0 100644 --- a/packages/runtime-core/src/provider/lifecycle.rs +++ b/packages/runtime-core/src/provider/lifecycle.rs @@ -2726,6 +2726,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..c4e75ac02 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(all(target_os = "macos", feature = "native-config-plan"), 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/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 61f47515d..f6c6e52f9 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -141,6 +141,80 @@ const restartPolicyStaleUses = [ { engine: "exited", bound: 3 }, ] as const; const contracts: readonly ModelContract[] = [ + { + name: "interactive-session", + module: "Session", + states: 459, + invariant: "NoReplay", + action: "ReplayStart", + fields: [ + 'stage = "started"', + "starts = 2", + "recorded = TRUE", + "intent = TRUE", + "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, + 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..5266931d3 --- /dev/null +++ b/src/backends/native-interactive-exec.ts @@ -0,0 +1,410 @@ +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 { + 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 = { + 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 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( + { 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, session), + 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: + report?.session && report.session !== session ? undefined : report, + session, + exit, + restorationFailed, + remedy, + check: () => pin.checkSync(), + }); + } + ); +} + +function spawnInteractiveChild( + opts: InteractiveOptions, + session: string +): 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", + "--session-id", + session, + "--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, + session: string +): Promise { + const retained = await (async () => { + check(); + return await invokeNativeRuntime({ + runtime: opts.runtime, + cwd: opts.projectRoot, + args: [ + "graph", + "interactive-sessions", + "--session", + session, + "--run-id", + opts.run, + "--service", + opts.service, + "--json", + ], + timeoutMs: 5000, + }); + })().catch(() => null); + if ( + isRecord(retained) && + keys(retained, "reconciled,sessions,version") && + retained.version === 1 && + retained.reconciled === null && + Array.isArray(retained.sessions) && + retained.sessions.length <= 1 && + retained.sessions.every( + (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" + ) + ) { + 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: evidence unavailable for session ${session}; cleanup: ${remedy} +` + ); + } +} + +async function finishInteractiveReport({ + opts, + report, + session, + exit, + restorationFailed, + remedy, + check, +}: { + opts: InteractiveOptions; + report?: Report; + session: string; + 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; 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) { + // 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, session); + return exit === 0 ? 1 : exit; + } + // 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) { + 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/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/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/src/lib/native-authored-command.ts b/src/lib/native-authored-command.ts index 2c7515aeb..41b459ef8 100644 --- a/src/lib/native-authored-command.ts +++ b/src/lib/native-authored-command.ts @@ -65,7 +65,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.", }); } @@ -963,8 +963,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..60396ba7e --- /dev/null +++ b/tests/fixtures/native-interactive-frontend.py @@ -0,0 +1,245 @@ +"""Private controlling PTY for the real Bun frontend, including compiled Bun.""" +import fcntl +import json +import os +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])) + 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: + 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[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) + 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) + 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:] +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", "retained-zero", "cleared"): + for name in ("reader.pid", "reader.closed", "session.queried", "session.cleared"): + 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) + 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) + 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": + os.kill(frontend, signal.SIGKILL) + elif mode == "cancel": + os.kill(frontend, signal.SIGTERM) + code = record(control, master, output, b"E", 10, mode) + 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": + 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" + 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 + assert b"synthetic-parent-key-canary" not in output + 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: session " 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 + 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 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) +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: nine lifetime and five selection cases passed") diff --git a/tests/fixtures/native-interactive-frontend.ts b/tests/fixtures/native-interactive-frontend.ts new file mode 100644 index 000000000..0e358a038 --- /dev/null +++ b/tests/fixtures/native-interactive-frontend.ts @@ -0,0 +1,29 @@ +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, + 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..2b10aa74a --- /dev/null +++ b/tests/fixtures/native-interactive-runtime.py @@ -0,0 +1,64 @@ +"""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 + +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 = sys.argv[sys.argv.index("--session" if "interactive-sessions" in sys.argv else "--session-id") + 1] +if "interactive-sessions" in sys.argv: + 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) +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("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 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") + 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": exit_code, "retained": mode != "normal"}}).encode() + b"\n") + # Deliberately leave raw mode: the frontend's independent snapshot owns it. + 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) +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 new file mode 100644 index 000000000..a4c85b934 --- /dev/null +++ b/tests/fixtures/native-interactive-terminal.py @@ -0,0 +1,189 @@ +"""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 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. + 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() + 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()), + "HACK_INTERACTIVE_FINISH": str(child_finish.fileno())} + + 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( + command, + stdin=slave, stdout=slave, stderr=slave, env=env, + 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 + 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)) + 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") + 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; + # 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 wait_draining(process, master, 5) == 0 + finally: + if process.poll() is None: + process.kill() + 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", "background"): + run(mode) +print("Native terminal: flood, resize, idle leash, signal and background 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..55ed502e1 --- /dev/null +++ b/tests/models/tla/interactive-session/COVERAGE.md @@ -0,0 +1,55 @@ +# Interactive session receipt + +`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 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 +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 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 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 +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. + + +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 new file mode 100644 index 000000000..fdc80c9e9 --- /dev/null +++ b/tests/models/tla/interactive-session/Session.tla @@ -0,0 +1,69 @@ +------------------------------ MODULE Session ------------------------------ +EXTENDS Naturals, TLC +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 == /\ 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 <> +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 \/ exitedValidated \/ (RetireOnFailedInspect /\ inspectFailed) \/ (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 <> +\* 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 \/ exitedValidated +============================================================================= 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..8604bdf4a --- /dev/null +++ b/tests/models/tla/interactive-session/eof-retirement.cfg @@ -0,0 +1,7 @@ +CONSTANTS RetireOnFailedInspect = FALSE + Replay = FALSE + RetireOnEOF = TRUE + WrongBoot = FALSE +SPECIFICATION Spec +INVARIANTS TypeOK WriteAhead NoReplay RetiredWithProof +CHECK_DEADLOCK FALSE 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 new file mode 100644 index 000000000..ef5e0d9f5 --- /dev/null +++ b/tests/models/tla/interactive-session/negative.cfg @@ -0,0 +1,7 @@ +CONSTANTS RetireOnFailedInspect = FALSE + 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..23b1387c1 --- /dev/null +++ b/tests/models/tla/interactive-session/positive.cfg @@ -0,0 +1,7 @@ +CONSTANTS RetireOnFailedInspect = FALSE + 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..724f4a6c2 --- /dev/null +++ b/tests/models/tla/interactive-session/wrong-boot.cfg @@ -0,0 +1,7 @@ +CONSTANTS RetireOnFailedInspect = FALSE + 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 8da1bb096..0b4474937 100644 --- a/tests/native-authored-command.test.ts +++ b/tests/native-authored-command.test.ts @@ -702,7 +702,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-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-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); 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 new file mode 100644 index 000000000..7330f21ab --- /dev/null +++ b/tests/native-interactive-exec.test.ts @@ -0,0 +1,148 @@ +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 { 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"; +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 contain keys, 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"); + } + 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${runtimeSource.replace('["__PROTECTED_KEYS__"]', protectedKeys)}`, + { 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, + protectedKeys, + process.execPath, + source, + ], + [ + python, + resolve("tests/fixtures/native-interactive-frontend.py"), + root, + protectedKeys, + compiled, + ], + ]; + for (const cmd of commands) { + const child = Bun.spawn(cmd, { + env: { + ...env, + ...Object.fromEntries( + [...HACK_KEY_MATERIAL_VARIABLES].map((name) => [ + name, + "synthetic-parent-key-canary", + ]) + ), + }, + 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: nine lifetime and five selection cases passed\n" + ); + } + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }, + 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-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 +); diff --git a/tests/native-project-exec.test.ts b/tests/native-project-exec.test.ts index 33f1e6157..59878109f 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(