Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d563099
feat(native): add interactive exec with retained session cleanup
roodboi Nov 13, 2026
ae0460f
Merge remote-tracking branch 'origin/next' into codex/hack-2065-inter…
roodboi Nov 13, 2026
7396b61
fix(native): keep interactive launcher within publication budget
roodboi Nov 13, 2026
28d7847
fix(native): align interactive exec macOS feature gates
roodboi Nov 13, 2026
fdc717c
fix(native): set interactive terminal size before exec starts
roodboi Nov 13, 2026
90dd62b
Merge commit '021b16bc' into codex/hack-2065-interactive-exec
roodboi Nov 13, 2026
180fd51
test(native): verify interactive child key containment
roodboi Nov 13, 2026
8821da5
fix(native): scope interactive env delivery to selected service
roodboi Nov 13, 2026
66a8edd
Merge remote-tracking branch 'origin/next' into codex/hack-2065-inter…
roodboi Nov 13, 2026
9e7cea4
fix(native): preserve exec output after macOS peer closure
roodboi Nov 13, 2026
5bb2a4d
Merge next into native interactive exec
roodboi Nov 13, 2026
8d16c9d
fix(native): bound descriptor parsing to copied control bytes
roodboi Nov 13, 2026
3671f6e
Merge remote-tracking branch 'origin/next' into codex/hack-2065-inter…
roodboi Nov 13, 2026
0c7d926
fix(native): bound interactive I/O and retire confirmed sessions
roodboi Nov 13, 2026
f7bd62f
fix(native): retain session identity across authored handoff failures
roodboi Nov 13, 2026
6bf17ae
test(native): budget retained-file setup before deadline refusals
roodboi Nov 13, 2026
6780016
chore: merge next into interactive exec fixes
roodboi Nov 13, 2026
11b6ae7
fix(native): restore terminals before quiet session retirement
roodboi Nov 13, 2026
a3ddfdd
merge: integrate next into native interactive sessions
roodboi Nov 13, 2026
f507a5b
style(native): order merged engine modules
roodboi Nov 13, 2026
2e80101
Merge remote-tracking branch 'origin/next' into codex/hack-2065-inter…
roodboi Nov 13, 2026
641871e
Merge remote-tracking branch 'origin/next' into codex/hack-2065-inter…
roodboi Nov 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions docs/diagrams/interactive-session.mmd
Original file line number Diff line number Diff line change
@@ -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
66 changes: 66 additions & 0 deletions docs/diagrams/interactive-session.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
85 changes: 83 additions & 2 deletions docs/guides/native-candidate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/guides/v5-release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
21 changes: 21 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
76 changes: 72 additions & 4 deletions docs/reference/native-authored-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
Loading
Loading