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 @@
+
\ 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.
+
+
+
+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