Skip to content

fix(server): consistent sandbox access after the main process exits - #4950

Open
pimlock wants to merge 9 commits into
mainfrom
4949-sandbox-access-after-exit/pimlock
Open

pimlock wants to merge 9 commits into
mainfrom
4949-sandbox-access-after-exit/pimlock

Conversation

@pimlock

@pimlock pimlock commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

A retained sandbox stays reachable after its main process exits, but only through some of the ways into it. This PR puts every way in behind one access check, so they all agree on when a sandbox can be used.

Related Issue

Closes #4949

What's inconsistent today

Each entry point checks the sandbox phase on its own, and they don't agree:

Entry point Ready Completed / Error
SSH, file transfer, port forward allowed allowed if the supervisor is connected to the replica that got the request
Exec, interactive exec allowed refused
Exposed services allowed refused
CLI exec (client-side check) allowed refused before calling the gateway

For example, a job writes result.txt and exits 0. The sandbox moves to Completed and its supervisor stays connected. Then:

openshell sandbox download job result.txt ./   # works
openshell sandbox exec --name job -- cat result.txt
# Error: sandbox 'job' is not ready (phase: Completed); wait for it to reach Ready state

With several gateway replicas, even the paths that do work after exit are unreliable. They only look for the supervisor on the replica that received the request, so the same download works or fails depending on where the load balancer sends it.

What changes

One rule now covers every way in:

  • Ready: reachable.
  • Completed or Error: reachable while the supervisor is connected to any replica.
  • Anything else: refused.

Exposed services have one extra limit. They don't authenticate callers, so they reach an Error sandbox only when its main process failed. A sandbox in Error because of an infrastructure failure stays reachable for debugging only through the authenticated paths.

The rule lives in a new gateway module, sandbox_access. Every entry point calls sandbox_access::acquire and opens its relay through the handle it returns, so none of them can skip the check:

flowchart LR
  exec[ExecSandbox] --> acquire
  iexec[ExecSandboxInteractive] --> acquire
  ssh[CreateSshSession] --> acquire
  fwd[ForwardTcp<br/>SSH, file transfer, port forward] --> acquire
  svc[Exposed services<br/>every request] --> acquire
  acquire{"sandbox_access::acquire<br/>phase + supervisor on any replica"} -->|SandboxAccess| relay[open_relay] --> sup[Supervisor]
  acquire -->|refused| err[FailedPrecondition<br/>sandbox is not ready]
Loading

The same example after this PR:

openshell sandbox exec --name job -- cat result.txt   # prints the result
openshell sandbox delete job

The gateway is the only place that decides. The CLI no longer checks the phase before exec. When the gateway refuses, the CLI error still names the phase it saw and what to do about it. A service forward in the CLI or the TypeScript SDK stops only when its sandbox is stopping, stopped or being deleted, and leaves the per-connection decision to the gateway.

New ways into a sandbox should go through acquire as well. So should anything that has to happen before a sandbox can be used, such as resuming a suspended sandbox.

Changes

  • Gateway: new sandbox_access module. Exec, interactive exec, SSH sessions, port forwarding and exposed services go through it. Exposed services check access on every request, including requests served over a pooled upstream connection. supervisor_session::supervisor_connected checks all replicas through the owner record.
  • CLI and TypeScript SDK: leave the access decision to the gateway.
  • Docs and the CLI skill: what still works after a retained sandbox's main process exits, and that --no-keep sandboxes are deleted.
  • Conformance: two CLI tests in tests/suites/conformance/tests/cli.rs, each run for exit codes 0 and 3. A --no-keep sandbox is deleted. A retained sandbox records the exit code and still accepts exec.

Testing

  • Checks appropriate to the affected code and behavior pass
  • Unit tests added/updated
  • E2E tests added/updated

I ran the conformance checks against a local Docker gateway before the sandbox_access refactor and before the conformance suite moved to testkit. Unit tests and the CLI exec integration tests cover the refactor. I haven't run the testkit version of the conformance tests, and I haven't tested Kubernetes, Podman or a real multi-replica gateway live.

Still broken and tracked in #4949: after a Docker gateway restart, Completed and Error sandboxes lose their supervisor and can't be reached.

Checklist

  • Follows Conventional Commits
  • Commits are signed off (DCO)
  • Architecture docs updated (if applicable)

@github-actions

Copy link
Copy Markdown

@pimlock
pimlock requested a review from drew October 31, 2026 01:07
@pimlock pimlock added area:supervisor Proxy and routing-path work test:e2e Requires end-to-end coverage labels Oct 31, 2026
@github-actions

This comment was marked as outdated.

@pimlock
pimlock requested a review from TaylorMutch October 31, 2026 01:08
@pimlock
pimlock force-pushed the 4949-sandbox-access-after-exit/pimlock branch from 81d00f9 to 67c24b1 Compare November 2, 2026 17:10
After the main process exits, the supervisor keeps its access plane, and
SSH and port forwarding already reached Completed and Error sandboxes
while it stayed connected. Exec still required Ready in both the CLI
and the gateway, so it was refused on the same sandboxes.

Exec, SSH sessions, and TCP forwarding now share one gate: a sandbox is
reachable when it is Ready, or Completed/Error with a supervisor session
on this replica or a fresh owner record naming a peer replica. The
previous check only looked at the local session map, so on a
multi-replica gateway a request landing on a non-owner replica was
refused even though relay opening routes to the owner.

The CLI exec pre-check and the service forward recheck accept Ready,
Completed, and Error and leave the supervisor decision to the gateway.

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
…upervisor

Exposed-service HTTP routing still required Ready while port forwarding
to the same sandbox kept working after the main process exited. Service
routing now uses the shared relay reachability gate, so a retained
Completed or Error sandbox serves its exposed services while its
supervisor stays connected.

Document what stays reachable after a retained sandbox's main process
exits, and that --no-keep sandboxes are deleted once it does.

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Add two CLI conformance tests, each run for exit codes 0 and 3: an
ephemeral (--no-keep) sandbox returns the exit code and is deleted, and
a retained sandbox records Completed or Error with the exit code and
still accepts exec.

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Add a phase diagram to the Sandbox Lifecycle section and describe what
happens after the main process exits. A --no-keep sandbox is deleted. A
retained sandbox keeps its compute and still accepts exec, file
transfer, port forwarding and exposed services while its supervisor is
connected. sandbox connect fails because it attaches to the exited main
process, so the docs point to sandbox exec --tty for a shell.

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
…gate

Exec, interactive exec, SSH sessions, port forwarding, and exposed services
now call sandbox_access::acquire and open their relays through the returned
SandboxAccess handle. The phase rules live in one module, so anything that
must happen before a sandbox can be used applies to every entry point.
supervisor_session keeps only the replica-aware supervisor_connected check.

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Exec no longer checks the sandbox phase before calling the gateway; when
the gateway refuses, the CLI names the phase it saw. Service forwarding
stops only when the sandbox is gone or being deleted, and leaves each
connection to the gateway's access check.

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
Exposed services do not authenticate callers, so they now reach an Error
sandbox only when its main process failed. Exec, SSH, and forwarding keep
the debugging path into an infrastructure Error.

The CLI service forward stops again once its sandbox is stopping, stopped,
or being deleted, and the TypeScript SDK forward applies the same check
instead of requiring Ready. Exec refusals name what makes the sandbox
usable again.

supervisor_connected skips the owner index on a single replica, the
unused relay error variant is gone, and relay opening outside
sandbox_access is crate-private.

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
@pimlock
pimlock force-pushed the 4949-sandbox-access-after-exit/pimlock branch from 67c24b1 to ce03ae9 Compare November 4, 2026 21:47
`sandbox connect` fails, because it attaches to the main process and that
process has exited.

An `Error` caused by an infrastructure failure, such as

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Error covers two cases here: a main process that exited nonzero (MainProcessFailed) and an infrastructure failure. We considered giving a failed or exited main process its own phase, and also reusing Completed for every exit. We decided against both for this PR:

  • Older SDKs treat Completed as success in wait_ready, so a failed run would be reported as a successful one.
  • A new phase reaches older clients as an unknown value, and they keep polling until they time out.

This PR keeps the existing phases. The access rule doesn't need a new phase to be correct.

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
@pimlock
pimlock requested a review from elezar November 5, 2026 21:18

/// Phases in which no forwarded connection can succeed until another command
/// starts or recreates the sandbox.
fn forward_ended_by_phase(phase: i32) -> bool {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does this function not represent a decision by the CLI? Would adding a property to the sandbox or a filter on the GetSandbox request make sense?

@elezar elezar left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for tackling this. A single acquire gate with a capability handle is a good fit for the problem, and the multi-replica owner-record handling reads correctly. I have no blockers on the gateway logic. My comments fall into two groups:

  1. Exposed services after exit. This is the one behavioural expansion I'd like an explicit decision on (comments on service_routing.rs and sandbox_access.rs).
  2. Clients re-implementing the gateway's rules. The CLI and SDK keep their own phase lists, which weakens the "gateway is the only place that decides" design. Carrying the refusal reason in the error would remove them (comments on sandbox_access.rs, run.rs, client.ts). These are non-blocking.

Test coverage. CI conformance passes on Docker, Podman, k3s and k3s-ha (three replicas), which is good coverage, and the new retained-sandbox tests run there. Two gaps remain:

  • It would be worth confirming that the k3s-ha run served a request on a replica other than the one holding the supervisor, or adding a test that forces it. Otherwise only the peer_holds_session unit test covers the cross-replica path directly.
  • I don't see end-to-end coverage for exposed services on a Completed sandbox. Only the unit test completed_sandbox_routes_services_only_while_supervisor_is_live covers it.

}
// Every request passes the access gate, including ones served from a
// pooled upstream connection.
let access = match sandbox_access::acquire_for_exposed_service(&state, &sandbox).await {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is the one change I'd like an explicit decision on. Before, a service request to a Completed sandbox (or an Error one whose main process failed) was refused. Now it's relayed through the retained supervisor, and the gateway doesn't authenticate service callers (auth is the service's own, per ServiceAuthorizationMode). If anything a job left behind is still listening on the endpoint's port, anyone with the service URL can reach it after exit.

Two questions: (a) does the supervisor tear down the workload's process tree on main-process exit, so nothing can be listening? If so, a test or a note here would settle it. (b) What's the use case for serving after exit? #4949 needs exec, SSH and file transfer, which are all authenticated. If services don't need it, I'd limit acquire_for_exposed_service to Ready. Otherwise I'd make it opt-in per endpoint.

sandbox: &Sandbox,
) -> Result<SandboxAccess, AccessError> {
if sandbox.phase() == SandboxPhase::Error as i32
&& !crate::compute::is_failed_main_process_result(sandbox)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Related to the above. The failed-main-process Error carve-out for exposed services is the most permissive unauthenticated case, so it's worth stating the reasoning in the doc comment. If post-exit service access is deliberate, say why. If it isn't, this branch goes away together with the previous comment.

#[derive(Debug)]
pub enum AccessError {
/// The sandbox is in a state that does not allow access.
NotReachable,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggestion: let NotReachable carry a reason and attach it as a google.rpc.ErrorInfo on the existing FailedPrecondition, keeping the message "sandbox is not ready". acquire already knows which branch it took. The repo already does this (openshell-core::rpc_error, and provider.rs keys on an ErrorInfo reason), so it follows convention.

Something like: PhaseNotServing, Stopped, Deleting, SupervisorDisconnected, ServiceInfraError, with the phase in metadata. That needs splitting Stopped/Deleting out of the _ => false arm. Older clients are unaffected, and newer clients against an older gateway fall back to the raw status.


/// The gateway decides whether a sandbox can be used. When it refuses, name the
/// phase this command saw and what would make the sandbox usable again.
fn exec_start_error(status: Status, sandbox: &Sandbox) -> miette::Report {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This matches the gateway's message text ("sandbox is not ready") and picks a hint from a phase fetched earlier. Two problems: a reworded gateway message silently drops the hint, and "supervisor is no longer connected" can be wrong when the refusal had another cause (a Lookup error, or the phase changed since the fetch). With the reason from the sandbox_access.rs comment, this becomes a match on the validated ErrorInfo, in the style of provider_mutation_is_uncertain, and uses the gateway's phase.


/// Phases in which no forwarded connection can succeed until another command
/// starts or recreates the sandbox.
fn forward_ended_by_phase(phase: i32) -> bool {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This and the SDK check (sdk/typescript/src/client.ts:1754) keep their own copy of "stopping, stopped or deleting means no connection can succeed", which is a reachability rule outside the gateway. It'll drift (Suspended already differs). Related: a forward against Starting or Suspended used to fail up front with "not ready". Now it binds and every connection fails at the gateway.

Suggestion: at startup, ask the gateway and act on the reason from the sandbox_access.rs comment. Give up on Stopped or Deleting. For phases that can still become reachable, wait with a bounded retry and then fail with the gateway's reason. That removes both phase lists and restores a clear startup error. If you'd rather keep the lists, a comment pointing at sandbox_access.rs and a parity test would reduce the drift risk.

const ref = found.ref;
if (ref.phase !== 'ready') {
throw new SdkError('connect', `sandbox '${name}' is not ready (phase: ${ref.phase})`);
if (ref.phase === 'stopping' || ref.phase === 'stopped' || ref.phase === 'deleting') {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same point as the run.rs comment on forward_ended_by_phase: this is a second copy of the reachability phase list, and it moves the failure for Starting/Suspended from startup to each connection. Reading the reason from the gateway would let both clients drop it.

}

#[tokio::test]
async fn completed_sandbox_routes_services_only_while_supervisor_is_live() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good unit coverage for the gate. Since this is the expansion with the most security weight (see the comment on proxy_to_endpoint), an end-to-end check would be worth having, in either direction: that a service on a Completed sandbox is refused (if you take the Ready-only option), or that it's served and nothing unintended is reachable (if you keep it). Right now CI has no conformance test that touches it.

@elezar

elezar commented Nov 6, 2026

Copy link
Copy Markdown
Member

#5277 adds a features/host-gateway test that currently works around the multi-replica attach race this PR fixes (#4949). It runs its client through sandbox exec in a Ready sandbox, because on k3s-ha a short main process can finish before the CLI attaches and the receiving replica answers "sandbox is not ready".

If this PR lands first, update tests/suites/features/host-gateway/tests/host_gateway.rs to run the client as the main process of sandbox create and remove the workaround comment. The k3s-ha row would then confirm the fix on a real multi-replica gateway, which this PR's description says hasn't been tested.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:supervisor Proxy and routing-path work test:e2e Requires end-to-end coverage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: consistent sandbox access after the main process exits

2 participants