diff --git a/.agents/skills/helm-dev-environment/SKILL.md b/.agents/skills/helm-dev-environment/SKILL.md index b17831beb..077f70ccd 100644 --- a/.agents/skills/helm-dev-environment/SKILL.md +++ b/.agents/skills/helm-dev-environment/SKILL.md @@ -34,7 +34,7 @@ Also applies the upstream agent-sandbox CRDs/controller (pinned via `AGENT_SANDB in `tasks/scripts/helm-k3s-local.sh`, fetched from `github.com/kubernetes-sigs/agent-sandbox` releases), enables its OTLP tracing on v0.5 and later, installs an OTLP trace collector and UI in the `observability` namespace, -and preloads the default community sandbox image into k3d so the first sandbox create +and preloads the default sandbox image into k3d so the first sandbox create does not wait on a large registry pull. Traefik is disabled at cluster creation time. **Multi-worktree support:** the cluster name is derived from the last component of the @@ -52,7 +52,7 @@ Port mappings created at cluster time (cannot be changed without recreating): Override with env vars before running `helm:k3s:create`: - `HELM_K3S_LB_HOST_PORT` (default: `8080`) - `HELM_K3S_PRELOAD_SANDBOX_IMAGE` (default: - `ghcr.io/nvidia/openshell-community/sandboxes/base:latest`; set to an empty value to skip) + `nvcr.io/nvidia/base/ubuntu:24.04`; set to an empty value to skip) - `HELM_K3S_COLLECTOR_IMAGE` (default: `mcr.microsoft.com/dotnet/aspire-dashboard:latest`) - `HELM_K3S_COLLECTOR_HEALTH_TIMEOUT` (default: `120` seconds) diff --git a/.agents/skills/update-docs/SKILL.md b/.agents/skills/update-docs/SKILL.md index aa2db8cd5..86edcd273 100644 --- a/.agents/skills/update-docs/SKILL.md +++ b/.agents/skills/update-docs/SKILL.md @@ -63,7 +63,7 @@ For each relevant commit, determine which doc page(s) it affects. Use this mappi | `python/` (SDK changes) | `docs/reference/` or `docs/get-started/quickstart.mdx` | | `proto/` (API changes) | `docs/reference/` | | `deploy/` (Dockerfile, Helm) | `docs/sandboxes/manage-gateways.mdx`, `docs/about/architecture.mdx` | -| Community sandbox definitions | `docs/sandboxes/community-sandboxes.mdx` | +| Sandbox image behavior | `docs/sandboxes/manage-sandboxes.mdx` | If a commit does not map to any existing page but introduces a user-visible concept, flag it as needing a new page. diff --git a/.github/actions/build-rust-binary/action.yml b/.github/actions/build-rust-binary/action.yml index 099d6acf2..2bc4e3ce8 100644 --- a/.github/actions/build-rust-binary/action.yml +++ b/.github/actions/build-rust-binary/action.yml @@ -32,21 +32,10 @@ inputs: runs: using: composite steps: - - name: Hash development shell - id: dev-shell - shell: bash - run: | - shell_drv=$(nix eval --raw --impure .#devShells --apply 'shells: shells.${builtins.currentSystem}.default.drvPath') - echo "hash=$(nix hash file --type sha256 --base16 "$shell_drv")" >> "$GITHUB_OUTPUT" - - - name: Cache Rust artifacts - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: ./.github/actions/setup-rust with: - shared-key: binaries-${{ inputs.binary }}-${{ inputs.triple }}-${{ steps.dev-shell.outputs.hash }} - cache-on-failure: "true" - cache-workspace-crates: "true" - cache-bin: "false" - cmd-format: nix develop -c {0} + cache-key: binaries-v2-${{ inputs.binary }}-${{ inputs.triple }} + save-cache: "true" - name: Set version shell: nix develop -c bash -euo pipefail {0} diff --git a/.github/actions/setup-e2e-kind/action.yml b/.github/actions/setup-e2e-kind/action.yml index 835189566..e617a2771 100644 --- a/.github/actions/setup-e2e-kind/action.yml +++ b/.github/actions/setup-e2e-kind/action.yml @@ -80,3 +80,16 @@ runs: docker image save --platform linux/amd64 --output "$archive" "$image" kind load image-archive "$archive" --name "$CLUSTER_NAME" done + + - name: Preload default sandbox image into kind + shell: bash + env: + CLUSTER_NAME: ${{ inputs.cluster-name }} + SANDBOX_IMAGE: nvcr.io/nvidia/base/ubuntu:24.04 + run: | + set -euo pipefail + archive="${RUNNER_TEMP:-/tmp}/openshell-default-sandbox-linux-amd64.tar" + docker pull --platform linux/amd64 "$SANDBOX_IMAGE" + docker image inspect "$SANDBOX_IMAGE" --format 'default sandbox image: {{join .RepoDigests ", "}}' + docker image save --platform linux/amd64 --output "$archive" "$SANDBOX_IMAGE" + kind load image-archive "$archive" --name "$CLUSTER_NAME" diff --git a/.github/actions/setup-e2e-podman/action.yml b/.github/actions/setup-e2e-podman/action.yml index 630938331..3e6f481f4 100644 --- a/.github/actions/setup-e2e-podman/action.yml +++ b/.github/actions/setup-e2e-podman/action.yml @@ -122,5 +122,5 @@ runs: podman run --rm \ --cap-add=SETPCAP \ --volume "$probe:/openshell-capbset-probe:ro" \ - docker.io/library/alpine:3.22 \ + nvcr.io/nvidia/base/ubuntu:24.04 \ /openshell-capbset-probe diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml new file mode 100644 index 000000000..f6b579d26 --- /dev/null +++ b/.github/actions/setup-rust/action.yml @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Setup Rust +description: Configure the Nix development shell and Rust caches + +inputs: + cache-key: + description: Rust target cache namespace + required: true + save-cache: + description: Whether this job writes the Rust target cache + required: false + default: "false" + +runs: + using: composite + steps: + - name: Hash Nix development shell + id: dev-shell + shell: bash + run: | + shell_drv=$(nix eval --raw --impure .#devShells --apply 'shells: shells.${builtins.currentSystem}.default.drvPath') + echo "hash=$(nix hash file --type sha256 --base16 "$shell_drv")" >> "$GITHUB_OUTPUT" + + - name: Realize Nix development shell + shell: bash + run: nix develop -c true + + - name: Cache Rust target and registry + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + shared-key: ${{ inputs.cache-key }}-${{ steps.dev-shell.outputs.hash }} + save-if: ${{ inputs.save-cache }} + cache-on-failure: ${{ inputs.save-cache }} + cache-bin: "false" + cmd-format: nix develop -c {0} diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 31b590e57..04f1dc87e 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -105,8 +105,46 @@ jobs: - name: Check dependencies run: cargo deny check licenses bans sources - rust: - name: Rust (${{ matrix.system }}) + rust-format: + name: Rust format + needs: pr_metadata + if: needs.pr_metadata.outputs.should_run == 'true' + runs-on: linux-amd64-cpu8 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: ./.github/actions/setup-nix + with: + cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: Format + shell: nix develop -c bash -euo pipefail {0} + run: | + cargo fmt --all -- --check + cargo fmt --manifest-path e2e/rust/Cargo.toml --all -- --check + cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all -- --check + cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all -- --check + + cargo-lockfiles: + name: Cargo lockfiles + needs: pr_metadata + if: needs.pr_metadata.outputs.should_run == 'true' + runs-on: linux-amd64-cpu8 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: ./.github/actions/setup-nix + with: + cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: Verify Cargo lockfiles + shell: nix develop -c bash -euo pipefail {0} + run: tasks/scripts/check-cargo-lockfiles.sh + + rust-lint: + name: Rust lint (${{ matrix.system }}) needs: pr_metadata if: needs.pr_metadata.outputs.should_run == 'true' strategy: @@ -120,118 +158,197 @@ jobs: - runner: macos-15-xlarge system: aarch64-darwin runs-on: ${{ matrix.runner }} - timeout-minutes: 90 - defaults: - run: - shell: nix develop .#devShells.${{ matrix.system }}.default -c bash -euo pipefail {0} + timeout-minutes: 45 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + - uses: ./.github/actions/setup-nix with: - github_access_token: ${{ secrets.GITHUB_TOKEN }} + cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 + - uses: ./.github/actions/setup-rust with: - name: openshell - authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} - - - name: Realize Nix development shell - shell: bash - run: nix build --no-link ".#devShells.${{ matrix.system }}.default" - - - name: Cache Rust target and registry - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - # Keep branch-check caches partitioned by target system; lint - # and test intentionally share the same job-local target directory. - shared-key: rust-checks-${{ matrix.system }} - # Preserve compiled artifacts from failed lint/test runs so the next - # push to the same PR branch does not start from a cold cache. - cache-on-failure: "true" - cache-workspace-crates: "true" - cache-bin: "false" - cmd-format: nix develop .#devShells.${{ matrix.system }}.default -c {0} - - - name: Format - run: | - cargo fmt --all -- --check - cargo fmt --manifest-path e2e/rust/Cargo.toml --all -- --check - cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all -- --check - cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all -- --check + cache-key: rust-checks-v2 + save-cache: "false" - name: Lint + shell: nix develop -c bash -euo pipefail {0} run: | - cargo clippy --workspace --all-targets -- -D warnings - cargo clippy --manifest-path e2e/rust/Cargo.toml --all-targets -- -D warnings - cargo clippy --manifest-path examples/governance-interceptor/Cargo.toml --all-targets -- -D warnings - cargo clippy --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all-targets -- -D warnings + cargo clippy --locked --workspace --all-targets -- -D warnings + cargo clippy --locked --manifest-path e2e/rust/Cargo.toml --all-targets -- -D warnings + cargo clippy --locked --manifest-path examples/governance-interceptor/Cargo.toml --all-targets -- -D warnings + cargo clippy --locked --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all-targets -- -D warnings + + rust-test: + name: Rust test (${{ matrix.system }}) + needs: pr_metadata + if: needs.pr_metadata.outputs.should_run == 'true' + strategy: + fail-fast: false + matrix: + include: + - runner: linux-amd64-cpu8 + system: x86_64-linux + - runner: linux-arm64-cpu8 + system: aarch64-linux + - runner: macos-15-xlarge + system: aarch64-darwin + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: ./.github/actions/setup-nix + with: + cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - uses: ./.github/actions/setup-rust + with: + cache-key: rust-checks-v2 + save-cache: "true" - name: Test + shell: nix develop -c bash -euo pipefail {0} env: OPENSHELL_TELEMETRY_ENABLED: "false" run: | - cargo nextest run --profile ci --workspace --features openshell-server/test-support - cargo nextest run --config-file .config/nextest.toml --profile ci --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml + cargo nextest run --locked --profile ci --workspace --features openshell-server/test-support + cargo nextest run --locked --config-file .config/nextest.toml --profile ci --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml - - name: Verify standalone policy prover package - if: matrix.system == 'x86_64-linux' - env: - CARGO_NET_OFFLINE: "true" + rust-build-modes: + name: Rust build modes (${{ matrix.system }}) + needs: pr_metadata + if: needs.pr_metadata.outputs.should_run == 'true' + strategy: + fail-fast: false + matrix: + include: + - runner: linux-amd64-cpu8 + system: x86_64-linux + - runner: linux-arm64-cpu8 + system: aarch64-linux + - runner: macos-15-xlarge + system: aarch64-darwin + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: ./.github/actions/setup-nix + with: + cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - uses: ./.github/actions/setup-rust + with: + cache-key: rust-checks-v2 + save-cache: "false" + + - name: Verify build modes + shell: nix develop -c bash -euo pipefail {0} + run: | + cargo build --locked -p openshell-gateway --bin openshell-gateway + tasks/scripts/verify-telemetry-compiled-out.sh present target/debug/openshell-gateway + cargo build --locked -p openshell-gateway --bin openshell-gateway --no-default-features --features defaults-without-telemetry + tasks/scripts/verify-telemetry-compiled-out.sh absent target/debug/openshell-gateway + cargo build --locked -p openshell-supervisor --bin openshell-supervisor --no-default-features --features defaults-without-telemetry + tasks/scripts/verify-telemetry-compiled-out.sh absent target/debug/openshell-supervisor + cargo check --locked -p openshell-supervisor --all-targets --no-default-features --features system-ca-roots + + - name: Verify system CA roots dependency boundary + shell: nix develop -c bash -euo pipefail {0} + run: | + cargo tree --locked -p openshell-supervisor --prefix none --no-default-features --features system-ca-roots > /tmp/openshell-supervisor-dependencies.txt + if grep -Eq '^(webpki-roots|webpki-root-certs) v' /tmp/openshell-supervisor-dependencies.txt; then + echo "ERROR: bundled Mozilla roots found in system CA roots build" >&2 + cat /tmp/openshell-supervisor-dependencies.txt >&2 + exit 1 + fi + + - name: Verify standalone prover dependency boundary + shell: nix develop -c bash -euo pipefail {0} run: | - cargo test --locked -p openshell-prover-cli - cargo build --locked --release -p openshell-prover-cli --bin openshell-prover cargo tree --locked -p openshell-prover-cli --edges normal --prefix none > /tmp/openshell-prover-dependencies.txt if grep -Eq '^(openshell-(cli|server|sdk|bootstrap|tui|providers|core|policy)) v' /tmp/openshell-prover-dependencies.txt; then echo "ERROR: standalone prover includes an OpenShell control-plane dependency" >&2 cat /tmp/openshell-prover-dependencies.txt >&2 exit 1 fi - target/release/openshell-prover check \ - crates/openshell-prover-cli/tests/fixtures/candidate-contained.yaml \ - --boundary crates/openshell-prover-cli/tests/fixtures/boundary.yaml \ - --output json > /tmp/openshell-prover-result.json - grep -q '"result"[[:space:]]*:[[:space:]]*"within_boundary"' /tmp/openshell-prover-result.json - - name: Verify telemetry can be compiled out + rust-feature-alias: + name: Rust feature alias + needs: pr_metadata + if: needs.pr_metadata.outputs.should_run == 'true' + runs-on: linux-amd64-cpu8 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: ./.github/actions/setup-nix + with: + cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - uses: ./.github/actions/setup-rust + with: + cache-key: rust-checks-v2 + save-cache: "false" + + - name: Verify defaults-without-telemetry feature alias + shell: nix develop -c bash -euo pipefail {0} run: | - cargo build -p openshell-gateway --bin openshell-gateway - tasks/scripts/verify-telemetry-compiled-out.sh present target/debug/openshell-gateway - cargo build -p openshell-gateway --bin openshell-gateway --no-default-features --features defaults-without-telemetry - tasks/scripts/verify-telemetry-compiled-out.sh absent target/debug/openshell-gateway - cargo build -p openshell-supervisor --bin openshell-supervisor --no-default-features --features defaults-without-telemetry - tasks/scripts/verify-telemetry-compiled-out.sh absent target/debug/openshell-supervisor - - - name: Verify selective gateway compute-driver builds - run: | - cargo test -p openshell-gateway --all-targets --no-default-features - cargo test -p openshell-gateway --all-targets --no-default-features --features compute-driver-docker - cargo test -p openshell-gateway --all-targets --no-default-features --features compute-driver-kubernetes - cargo test -p openshell-gateway --all-targets --no-default-features --features compute-driver-podman - cargo test -p openshell-gateway --all-targets --no-default-features --features compute-driver-vm - cargo test -p openshell-gateway --all-targets --no-default-features --features compute-driver-mxc - cargo test -p openshell-gateway --all-targets --no-default-features --features compute-driver-docker,compute-driver-vm - - - name: Verify the defaults-without-telemetry feature alias tracks the default feature set - run: tasks/scripts/verify-defaults-without-telemetry.sh - - - name: Verify system CA roots build mode compiles and excludes bundled Mozilla roots - run: | - cargo check -p openshell-supervisor --all-targets --no-default-features --features system-ca-roots - if cargo tree -p openshell-supervisor -i webpki-roots --no-default-features --features system-ca-roots 2>/dev/null | grep -q webpki-roots; then - echo "ERROR: webpki-roots found in system CA roots build" >&2 - exit 1 - fi - if cargo tree -p openshell-supervisor -i webpki-root-certs --no-default-features --features system-ca-roots 2>/dev/null | grep -q webpki-root-certs; then - echo "ERROR: webpki-root-certs found in system CA roots build" >&2 - exit 1 - fi - - - name: Verify Cargo lockfiles unchanged - run: | - tasks/scripts/check-cargo-lockfiles.sh + tasks/scripts/verify-defaults-without-telemetry.sh git diff --exit-code HEAD -- ':(glob)**/Cargo.lock' + rust-gateway-core-features: + name: Rust gateway core features + needs: pr_metadata + if: needs.pr_metadata.outputs.should_run == 'true' + runs-on: linux-amd64-cpu8 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: ./.github/actions/setup-nix + with: + cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - uses: ./.github/actions/setup-rust + with: + cache-key: rust-checks-v2 + save-cache: "false" + + - name: Verify core gateway compute-driver feature sets + shell: nix develop -c bash -euo pipefail {0} + run: | + cargo nextest run --locked --profile ci -p openshell-gateway --all-targets --no-default-features + cargo nextest run --locked --profile ci -p openshell-gateway --all-targets --no-default-features --features compute-driver-docker + cargo nextest run --locked --profile ci -p openshell-gateway --all-targets --no-default-features --features compute-driver-kubernetes + cargo nextest run --locked --profile ci -p openshell-gateway --all-targets --no-default-features --features compute-driver-podman + + rust-gateway-extended-features: + name: Rust gateway extended features + needs: pr_metadata + if: needs.pr_metadata.outputs.should_run == 'true' + runs-on: linux-amd64-cpu8 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: ./.github/actions/setup-nix + with: + cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - uses: ./.github/actions/setup-rust + with: + cache-key: rust-checks-v2 + save-cache: "false" + + - name: Verify extended gateway compute-driver feature sets + shell: nix develop -c bash -euo pipefail {0} + run: | + cargo nextest run --locked --profile ci -p openshell-gateway --all-targets --no-default-features --features compute-driver-vm + cargo nextest run --locked --profile ci -p openshell-gateway --all-targets --no-default-features --features compute-driver-mxc + cargo nextest run --locked --profile ci -p openshell-gateway --all-targets --no-default-features --features compute-driver-docker,compute-driver-vm + python: name: Python (${{ matrix.runner }}) needs: pr_metadata diff --git a/Cargo.lock b/Cargo.lock index a979d700c..fe72444b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,7 +155,7 @@ checksum = "947e21ff51879f8a40d7519dfe619268de2afba4042a8a43878276de3cb910f0" dependencies = [ "memchr", "rowan", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -691,7 +691,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" dependencies = [ "fastrand", - "gloo-timers", + "gloo-timers 0.3.0", "tokio", ] @@ -772,9 +772,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" dependencies = [ "serde_core", ] @@ -851,7 +851,7 @@ dependencies = [ "serde_derive", "serde_json", "serde_urlencoded", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-util", "tower-service", @@ -894,9 +894,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bytes-utils" @@ -997,9 +997,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1293,7 +1293,7 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "crossterm_winapi", "libc", "mio 0.8.11", @@ -1309,7 +1309,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "crossterm_winapi", "mio 1.2.0", "parking_lot", @@ -1486,6 +1486,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -2271,6 +2285,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gloo-timers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "group" version = "0.13.0" @@ -2416,7 +2442,7 @@ dependencies = [ "once_cell", "rand 0.10.2", "ring", - "thiserror 2.0.18", + "thiserror 2.0.20", "tinyvec", "tracing", "url", @@ -2880,7 +2906,7 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "inotify-sys", "libc", ] @@ -3014,7 +3040,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -3072,13 +3098,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -3102,7 +3127,7 @@ dependencies = [ "jsonptr", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3115,7 +3140,7 @@ dependencies = [ "pest_derive", "regex", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3216,7 +3241,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "libc", ] @@ -3262,7 +3287,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-util", "tower", @@ -3285,7 +3310,7 @@ dependencies = [ "serde", "serde-value", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3324,7 +3349,7 @@ dependencies = [ "pin-project", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -3338,7 +3363,7 @@ checksum = "49fefd6652c57d68aaa32544a4c0e642929725bdc1fd929367cdeb673ab81088" dependencies = [ "enumflags2", "libc", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3390,7 +3415,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "libc", "plain", "redox_syscall 0.7.4", @@ -3436,9 +3461,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru" @@ -3526,7 +3551,7 @@ dependencies = [ "metrics", "metrics-util", "quanta", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", ] @@ -3669,7 +3694,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -3681,7 +3706,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -3703,7 +3728,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "fsevent-sys", "inotify", "kqueue", @@ -3721,7 +3746,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", ] [[package]] @@ -3876,7 +3901,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", "unicase", @@ -3896,7 +3921,7 @@ dependencies = [ "serde_json", "strum 0.27.2", "strum_macros 0.27.2", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -4001,7 +4026,7 @@ dependencies = [ "tar", "temp-env", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tokio-stream", @@ -4062,7 +4087,7 @@ dependencies = [ "sha2 0.10.9", "tar", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-stream", "tonic", @@ -4160,7 +4185,7 @@ dependencies = [ "serde", "serde_json", "temp-env", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-stream", "toml", @@ -4204,7 +4229,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-stream", "tonic", @@ -4238,7 +4263,7 @@ dependencies = [ "serde_json", "tar", "temp-env", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-stream", "tonic", @@ -4332,7 +4357,7 @@ dependencies = [ "rustls", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-stream", "tonic", @@ -4378,7 +4403,7 @@ dependencies = [ "prost-types", "serde_json", "sha2 0.10.9", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tonic", "tracing", @@ -4421,7 +4446,7 @@ dependencies = [ "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tonic", "tower-http", @@ -4506,7 +4531,7 @@ dependencies = [ "prost-types", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", ] @@ -4534,6 +4559,7 @@ dependencies = [ "openshell-sandbox-backend", "rand 0.10.2", "rcgen", + "russh-sftp", "rustix 1.1.4", "rustls", "rustls-pemfile", @@ -4549,6 +4575,7 @@ dependencies = [ "tonic", "tracing", "tracing-subscriber", + "uuid", ] [[package]] @@ -4570,7 +4597,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tokio-stream", @@ -4597,7 +4624,7 @@ dependencies = [ "rustls", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tokio-stream", @@ -4681,7 +4708,7 @@ dependencies = [ "sqlx", "sqlx-core", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tokio-stream", @@ -4819,7 +4846,7 @@ dependencies = [ "spiffe", "temp-env", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tokio-stream", @@ -4889,7 +4916,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", ] @@ -4903,7 +4930,7 @@ dependencies = [ "once_cell", "shell-escape", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", ] @@ -4923,7 +4950,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", ] @@ -4938,7 +4965,7 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tonic", "tonic-types", @@ -4970,7 +4997,7 @@ dependencies = [ "percent-encoding", "portable-atomic", "rand 0.9.4", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-stream", ] @@ -5635,7 +5662,7 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "memchr", "unicase", ] @@ -5678,7 +5705,7 @@ dependencies = [ "rustc-hash 2.1.2", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -5701,7 +5728,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -5842,7 +5869,7 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "cassowary", "compact_str", "crossterm 0.27.0", @@ -5862,7 +5889,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", ] [[package]] @@ -5885,7 +5912,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", ] [[package]] @@ -5894,7 +5921,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", ] [[package]] @@ -5949,7 +5976,7 @@ dependencies = [ "serde_json", "serde_yaml", "spin", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -6127,7 +6154,7 @@ checksum = "da7c230e0ed9cbeb92fbad6c8848985d6df2a1464c0dc247a021abd666e9005e" dependencies = [ "aes", "aws-lc-rs", - "bitflags 2.11.1", + "bitflags 2.13.2", "block-padding", "byteorder", "bytes", @@ -6184,7 +6211,7 @@ dependencies = [ "ssh-encoding", "ssh-key", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "typenum", "universal-hash", @@ -6203,6 +6230,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "russh-sftp" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093197e526668d92bba562e2bbbe98d1af9831bf080b619c736316ca1fa35101" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "chrono", + "dashmap", + "gloo-timers 0.4.0", + "log", + "serde", + "serde_bytes", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "wasm-bindgen-futures", +] + [[package]] name = "russh-util" version = "0.52.0" @@ -6257,7 +6304,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.4.15", @@ -6270,7 +6317,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", @@ -6497,7 +6544,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "core-foundation", "core-foundation-sys", "libc", @@ -6522,9 +6569,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -6541,23 +6588,33 @@ dependencies = [ ] [[package]] -name = "serde_core" -version = "1.0.228" +name = "serde_bytes" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.5", ] [[package]] @@ -6824,7 +6881,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", ] @@ -6876,7 +6933,7 @@ dependencies = [ "prost-types", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tokio", "tokio-util", @@ -6965,7 +7022,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-stream", "tracing", @@ -7006,7 +7063,7 @@ dependencies = [ "sqlx-postgres", "sqlx-sqlite", "syn 2.0.117", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "url", ] @@ -7017,7 +7074,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "byteorder", "bytes", "crc", @@ -7033,7 +7090,7 @@ dependencies = [ "sha1 0.11.0", "sha2 0.11.0", "sqlx-core", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", ] @@ -7045,7 +7102,7 @@ checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64", - "bitflags 2.11.1", + "bitflags 2.13.2", "byteorder", "crc", "dotenvy", @@ -7067,7 +7124,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "whoami", ] @@ -7091,7 +7148,7 @@ dependencies = [ "percent-encoding", "serde", "sqlx-core", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "url", ] @@ -7336,7 +7393,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -7405,11 +7462,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -7425,13 +7482,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.5", ] [[package]] @@ -7579,13 +7636,15 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", + "libc", "pin-project-lite", "slab", "tokio", @@ -7739,7 +7798,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "base64", - "bitflags 2.11.1", + "bitflags 2.13.2", "bytes", "futures-util", "http 1.4.0", @@ -7769,7 +7828,7 @@ dependencies = [ "base64", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -7798,7 +7857,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tracing-subscriber", ] @@ -7902,7 +7961,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1 0.10.6", - "thiserror 2.0.18", + "thiserror 2.0.20", "utf-8", ] @@ -7919,7 +7978,7 @@ dependencies = [ "log", "rand 0.9.4", "sha1 0.10.6", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -8159,9 +8218,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -8172,9 +8231,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -8182,9 +8241,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8192,22 +8251,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] @@ -8253,7 +8312,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.2", "hashbrown 0.15.5", "indexmap", "semver", @@ -8261,9 +8320,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -8837,7 +8896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.2", "indexmap", "log", "serde", diff --git a/README.md b/README.md index f23ddb63c..d441d5bcf 100644 --- a/README.md +++ b/README.md @@ -56,19 +56,18 @@ For deploying OpenShell on OpenShift, see [`deploy/helm/openshell/README.md#inst ### Create a sandbox ```bash -openshell sandbox create -- claude # or opencode, codex, copilot +openshell sandbox create --name demo ``` -The sandbox container includes the following tools by default: +The gateway defaults to `nvcr.io/nvidia/base/ubuntu:24.04`, a minimal Ubuntu +Noble workload. To run an agent, build or select an OCI image that contains the +agent and pass its explicit reference: -| Category | Tools | -| ---------- | -------------------------------------------------------- | -| Agent | `claude`, `opencode`, `codex`, `copilot` | -| Language | `python` (3.14), `node` (22) | -| Developer | `gh`, `git`, `vim`, `nano` | -| Networking | `ping`, `dig`, `nslookup`, `nc`, `traceroute`, `netstat` | +```bash +openshell sandbox create --from registry.example.com/agents/my-agent:1.0 -- my-agent +``` -For more details see https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base. +Attach the providers and policy required by that workload. ### See network policy in action @@ -184,25 +183,27 @@ Inference access uses the same provider workflow. Attach an inference-capable pr OpenShell can pass host GPUs into sandboxes for local inference, fine-tuning, or any GPU workload. Add `--gpu` when creating a sandbox: ```bash -openshell sandbox create --gpu --from [gpu-enabled-sandbox] -- claude +openshell sandbox create --gpu --from registry.example.com/your-org/gpu-agent:latest -- claude ``` Docker-backed GPU sandboxes auto-select CDI when available and otherwise fall back to Docker's NVIDIA GPU request path (`--gpus all`). -**Requirements:** NVIDIA drivers and the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) must be installed on the host. The sandbox image itself must include the appropriate GPU drivers and libraries for your workload — the default `base` image does not. See the [BYOC example](https://github.com/NVIDIA/OpenShell/tree/main/examples/bring-your-own-container) for building a custom sandbox image with GPU support. +**Requirements:** NVIDIA drivers and the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) must be installed on the host. The sandbox image itself must include the appropriate GPU drivers and libraries for your workload — the default Ubuntu image does not. See the [BYOC example](https://github.com/NVIDIA/OpenShell/tree/main/examples/bring-your-own-container) for building a custom sandbox image with GPU support. ## Supported Agents -| Agent | Source | Notes | -| ------------------------------------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | Works out of the box. Provider uses `ANTHROPIC_API_KEY`. | -| [OpenCode](https://opencode.ai/) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | Works out of the box. Provider uses `OPENAI_API_KEY` or `OPENROUTER_API_KEY`. | -| [Codex](https://developers.openai.com/codex) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | Works out of the box. Provider uses `OPENAI_API_KEY`. | -| [GitHub Copilot CLI](https://docs.github.com/en/copilot/github-copilot-in-the-cli) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | Works out of the box. Provider uses `GITHUB_TOKEN` or `COPILOT_GITHUB_TOKEN`. | -| [OpenClaw](https://openclaw.ai/) | [NemoClaw](https://github.com/NVIDIA/NemoClaw) | Run OpenClaw more securely inside NVIDIA OpenShell with the NemoClaw blueprint. | -| [Hermes Agent](https://github.com/NousResearch/hermes-agent) | [NemoClaw](https://github.com/NVIDIA/NemoClaw) | Run Hermes Agent more securely inside NVIDIA OpenShell with the NemoClaw blueprint. | -| [Ollama](https://ollama.com/) | [Community](https://github.com/NVIDIA/OpenShell-Community) | Launch with `openshell sandbox create --from ollama`. | -| [Pi](https://pi.dev/) | [Community](https://github.com/NVIDIA/OpenShell-Community) | Launch with `openshell sandbox create --from pi`. | +OpenShell can run Linux agents packaged in OCI images. The default Ubuntu +workload does not bundle agent CLIs. Build or select an image containing your +agent, then authorize its binary paths, service endpoints, and credentials. + +| Agent | Integration | +| ----- | ----------- | +| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | Package Claude Code in a workload image and attach a `claude-code` provider or another endpoint-bearing model profile. | +| [OpenCode](https://opencode.ai/) | Package OpenCode in a workload image and attach its model provider and policy. | +| [Codex](https://developers.openai.com/codex) | Package Codex in a workload image and attach an OpenAI provider and policy. | +| [GitHub Copilot CLI](https://docs.github.com/en/copilot/github-copilot-in-the-cli) | Package the CLI in a workload image and attach GitHub credentials and policy. | +| [OpenClaw](https://openclaw.ai/) | Use the [NemoClaw](https://github.com/NVIDIA/NemoClaw) blueprint. | +| [Hermes Agent](https://github.com/NousResearch/hermes-agent) | Use the [NemoClaw](https://github.com/NVIDIA/NemoClaw) blueprint. | ## Key Commands @@ -234,23 +235,23 @@ openshell term The TUI gives you a live, keyboard-driven view of your gateway and sandboxes. Navigate with `Tab` to switch panels, `j`/`k` to move through lists, `Enter` to select, and `:` for command mode. Gateway health and sandbox status auto-refresh every two seconds. -## Community Sandboxes and BYOC +## Workload Images and BYOC -Use `--from` to create sandboxes from the [OpenShell Community](https://github.com/NVIDIA/OpenShell-Community) catalog or a container image: +Use `--from` with an explicit OCI image reference: ```bash -openshell sandbox create --from gemini # community catalog -docker build -t my-sandbox:latest ./my-sandbox-dir # Docker gateway -openshell sandbox create --from my-sandbox:latest # Docker built image -podman build -t localhost/my-sandbox:latest ./my-sandbox-dir # Podman gateway -openshell sandbox create --from localhost/my-sandbox:latest # Podman built image -openshell sandbox create --from registry.io/img:v1 # container image +docker build -t my-sandbox:latest ./my-sandbox-dir +openshell sandbox create --from my-sandbox:latest + +podman build -t localhost/my-sandbox:latest ./my-sandbox-dir +openshell sandbox create --from localhost/my-sandbox:latest + +openshell sandbox create --from registry.example.com/agents/my-agent:1.0 ``` Build with the container engine used by your local gateway. For a remote -gateway, push the image to a registry that the gateway can pull from. - -See the [OpenShell Community](https://github.com/NVIDIA/OpenShell-Community) catalog and the [BYOC example](https://github.com/NVIDIA/OpenShell/tree/main/examples/bring-your-own-container) for details. +gateway, push the image to a registry that the gateway can pull from. See the +[BYOC example](https://github.com/NVIDIA/OpenShell/tree/main/examples/bring-your-own-container). ## Use OpenShell with Your Agent diff --git a/TESTING.md b/TESTING.md index 699f44a72..42495fc23 100644 --- a/TESTING.md +++ b/TESTING.md @@ -106,6 +106,21 @@ requires mTLS. ### Python E2E (`e2e/python/`) +`mise run e2e:python` builds `openshell/e2e-python:dev` from +`e2e/python/Dockerfile.workload` and selects it only for the test gateway. +This Noble-based fixture supplies the `sandbox` user, Python tooling, Git, +and a writable `/sandbox/.venv`. Its Python version comes from `.python-version` +so cloudpickle code objects match the test runner. The production workload +default remains the unmodified NVIDIA Ubuntu image. + +The Rust Docker harness also selects this fixture for tests that need tools +or the named user. Explicit `--from` or `--template` arguments and the default-image +tests retain their own image selection. Docker sandbox and support-container +fixtures use the fixed image name; no workload-image override is needed. +Podman, VM, and Kubernetes retain their existing pinned, pullable fixture and +image setup. Conformance-only runs do not need the Docker fixture. +Build the Docker fixture separately with `mise run e2e:workload:build`. + Tests use the `sandbox` fixture from `conftest.py` to create real sandboxes: ```python @@ -207,7 +222,7 @@ the current checkout. The phase-1 scenario verifies the complete CLI-to-gateway-to-driver path without depending on how the gateway was installed or which driver is configured. It requires machine-readable gRPC status, creates a uniquely named detached -sandbox with `--from base`, verifies the sandbox is `Ready` by finding its +sandbox with the configured default image, verifies the sandbox is `Ready` by finding its unique name in paginated JSON list output, executes `echo` with a run-specific marker, deletes the sandbox, and verifies that its name no longer appears. Driver suites enable the same profile diff --git a/architecture/build.md b/architecture/build.md index 121ab0449..a75f8ff6c 100644 --- a/architecture/build.md +++ b/architecture/build.md @@ -23,7 +23,7 @@ OpenShell builds these main artifacts: | VM driver/runtime assets | `crates/openshell-driver-vm` | | Published docs site | `docs/` rendered by Fern config in `fern/` | -Sandbox community images are built outside this repository. +Workload images are standard OCI images supplied by operators or users. ## Build Features @@ -102,6 +102,12 @@ with GNU libc and uses the same glibc 2.28 compatibility floor as the gateway. ## Container Builds +Docker E2E tool-dependent workloads use a dedicated Noble-based fixture, +separate from the product's minimal default image. The fixture supplies the +test identity and tools, with Python aligned to the host test runner for +serialized callable compatibility. Default-image coverage retains the product +image. Other compute-driver test lanes retain their existing workload fixtures. + The Docker image pipeline is a two-step flow: build the Rust binary natively for the target architecture, then assemble the container image from the prebuilt binary. The gateway, sandbox, and supervisor images use distinct @@ -266,8 +272,29 @@ revocation and the gateway's reauthorization-required recovery state. Tmachine environments define the guest machine and runtime setup, while named installers define how OpenShell is installed. This keeps the runtime mode independent from binary or package installation and lets multiple installers -reuse the same prepared setup disk. The test command is -`tmachine test `. +reuse the same prepared setup disk. The `none` installer skips OpenShell +installation and boots the prepared environment directly. + +### Interactive tmachine shell + +The test command is `tmachine test `. The +`shell` testsuite prepares the selected environment and installer, then opens an +interactive SSH session in the disposable guest for manual debugging. + +Start an Ubuntu Docker guest without installing OpenShell: + +```shell +nix run .#tmachine -- test ubuntu-docker-rootful none shell +``` + +Replace `none` with `deb` to install the locally staged Debian package before +opening the shell: + +```shell +nix run .#tmachine -- test ubuntu-docker-rootful deb shell +``` + +Exit the SSH session to shut down and discard the disposable guest. The `tests/tmachine` setup and install caches include a digest of the entire directory containing `ANSIBLE_CONFIG`, including local roles, task diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index 6e4fabb35..1ed9f9d90 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -403,9 +403,8 @@ readiness. ## Images -The gateway image and Helm chart are built from this repository. Sandbox images -are maintained separately in the OpenShell Community repository or supplied by -users. +The gateway image and Helm chart are built from this repository. Users supply +workload images as standard OCI images. Custom sandbox images must include the agent runtime and any system dependencies, but they should not need to include the gateway. GPU-capable diff --git a/architecture/gateway.md b/architecture/gateway.md index 6722977fd..8645104bc 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -1036,6 +1036,26 @@ alone — instead an unanswered keepalive on a wedged or orphaned relay closes t channel and returns the exec with an error. Once a command reports its exit status, the gateway also bounds how long it waits for the trailing channel close. +Interactive exec treats normal request-stream EOF as the end of stdin and resize +input. The gateway sends SSH EOF while keeping the output channel open until +command completion. Input errors terminate the operation rather than masquerading +as normal EOF. The input and output pumps are owned by the exec operation, so +timeout or response abandonment cannot leave a detached stdin task behind. +The pumps share polling fairly, and request processing yields cooperatively even +for ignored resize messages, so sustained input cannot monopolize the operation. + +Go and TypeScript interactive-exec helpers distinguish process exit from stream +completion. They consume the final gRPC status before reporting success and retain +an observed exit code if transport completion fails. Callers must drain output +concurrently with waiting for completion. + +TypeScript starts interactive exec eagerly and uses a bounded output queue between +the background receiver and the consumer. Cancellation wakes a receiver blocked +on that queue. Go exposes input closure through an optional session capability, +preserving the original interface for existing implementations. TypeScript also +preserves its original session interface; SDK-created sessions expose lifecycle +controls through an extended interface. + `ForwardTcp` is the client-facing byte stream for SSH and service forwarding. The first frame is a `TcpForwardInit` that carries the workspace-scoped sandbox name, an authorization token from `CreateSshSession`, and an explicit target: diff --git a/crates/openshell-cli/src/completers.rs b/crates/openshell-cli/src/completers.rs index c5fec2063..a0ab9a9b5 100644 --- a/crates/openshell-cli/src/completers.rs +++ b/crates/openshell-cli/src/completers.rs @@ -217,6 +217,7 @@ mod tests { with_vars( [ ("XDG_CONFIG_HOME", Some(tmp.as_str())), + ("OPENSHELL_SYSTEM_GATEWAY_DIR", Some(tmp.as_str())), ("OPENSHELL_GATEWAY", None::<&str>), ], f, diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 8d16dc773..f24aff917 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -320,7 +320,7 @@ const SANDBOX_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m \x1b[1mEXAMPLES\x1b[0m $ openshell sandbox create - $ openshell sandbox create --from python + $ openshell sandbox create --from registry.example.com/agents/python:latest $ openshell sandbox connect my-sandbox $ openshell sandbox list $ openshell sandbox delete my-sandbox @@ -1409,13 +1409,8 @@ enum SandboxCommands { #[arg(long, conflicts_with_all = ["from", "gpu", "cpu", "memory", "driver_config_json", "envs"])] template: Option, - /// Sandbox source: a community sandbox name (e.g., `ollama`), a rootfs - /// tar archive (`.tar`, `.tar.gz`, or `.tgz`), or a full container - /// image reference (e.g., `myregistry.com/img:tag`). - /// - /// Community names are resolved to - /// `ghcr.io/nvidia/openshell-community/sandboxes/:latest` - /// (override the prefix with `OPENSHELL_COMMUNITY_REGISTRY`). + /// Sandbox source: a rootfs tar archive (`.tar`, `.tar.gz`, or `.tgz`) + /// or a container image reference (e.g., `myregistry.com/img:tag`). /// /// To use a local Dockerfile, build and tag it with the container /// engine used by your local gateway, then pass the resulting image diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 53cfcd4c5..e78111cc0 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1350,7 +1350,7 @@ enum ResolvedSource { /// 1. Existing file with `.tar`, `.tar.gz`, or `.tgz` extension → rootfs tar archive. /// 2. Local Dockerfile and directory paths → an actionable build-and-tag error. /// 3. Other explicit local paths → an actionable error. -/// 4. Full image reference or community sandbox name → resolve as an image. +/// 4. Any other value is passed through as an explicit image reference. fn resolve_from(value: &str) -> Result { let path = Path::new(value); @@ -1391,11 +1391,7 @@ fn resolve_from(value: &str) -> Result { )); } - // Full image reference or community sandbox name — delegate to shared - // resolution in openshell-core. - Ok(ResolvedSource::Image( - openshell_core::image::resolve_community_image(value), - )) + Ok(ResolvedSource::Image(value.to_string())) } #[allow(clippy::case_sensitive_file_extension_comparisons)] // already lowercased @@ -6902,7 +6898,7 @@ mod tests { } #[test] - fn resolve_from_keeps_bare_community_name_when_local_directory_matches() { + fn resolve_from_keeps_bare_image_reference_when_local_directory_matches() { let _lock = TEST_ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -6914,11 +6910,8 @@ mod tests { let result = resolve_from("python"); std::env::set_current_dir(original_dir).expect("restore current directory"); - match result.expect("bare community name should not be a local path") { - super::ResolvedSource::Image(image) => assert_eq!( - image, - "ghcr.io/nvidia/openshell-community/sandboxes/python:latest" - ), + match result.expect("bare image reference should not be a local path") { + super::ResolvedSource::Image(image) => assert_eq!(image, "python"), other @ super::ResolvedSource::RootfsTar { .. } => { panic!("expected image source, got {other:?}"); } diff --git a/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs b/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs index a06de97a4..61a26558f 100644 --- a/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs +++ b/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs @@ -141,8 +141,6 @@ async fn create_running_sandbox( "create", "--name", sandbox_name, - "--from", - "base", "--detach", "--no-tty", "--", diff --git a/crates/openshell-conformance/src/scenarios/smoke.rs b/crates/openshell-conformance/src/scenarios/smoke.rs index 7da9ef221..e9ba070da 100644 --- a/crates/openshell-conformance/src/scenarios/smoke.rs +++ b/crates/openshell-conformance/src/scenarios/smoke.rs @@ -55,15 +55,7 @@ async fn run_smoke_inner(runner: &mut OpenShellRunner) -> Result<(), String> { .step("create") .description("sandbox creation succeeds") .with_timeout(CREATE_TIMEOUT) - .run(&[ - "sandbox", - "create", - "--name", - &sandbox_name, - "--from", - "base", - "--detach", - ]) + .run(&["sandbox", "create", "--name", &sandbox_name, "--detach"]) .await .map_err(|error| error.to_string())?; create.require_success()?; diff --git a/crates/openshell-core/src/image.rs b/crates/openshell-core/src/image.rs index e804afd60..e092b9afb 100644 --- a/crates/openshell-core/src/image.rs +++ b/crates/openshell-core/src/image.rs @@ -1,124 +1,29 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Shared image-name resolution for community sandbox images. -//! -//! Both the CLI and TUI need to expand bare sandbox names (e.g. `"base"`) into -//! fully-qualified container image references. This module centralises that -//! logic so every client resolves names identically. +//! Default workload image selection. -/// Default registry prefix for community sandbox images. +/// Default sandbox workload image. /// -/// Bare sandbox names are expanded to `{prefix}/{name}:latest`. -/// Override at runtime with the `OPENSHELL_COMMUNITY_REGISTRY` env var. -pub const DEFAULT_COMMUNITY_REGISTRY: &str = "ghcr.io/nvidia/openshell-community/sandboxes"; +/// `OpenShell` uses a version-qualified NVIDIA Ubuntu Noble image so a fresh +/// installation does not depend on a separately maintained image catalog. +pub const DEFAULT_SANDBOX_BASE_IMAGE: &str = "nvcr.io/nvidia/base/ubuntu:24.04"; -/// Return the default sandbox image reference (`{registry}/base:latest`). +/// Return the default sandbox image reference. /// /// Used by all compute drivers as the fallback image when none is specified in /// the sandbox spec. #[must_use] pub fn default_sandbox_image() -> String { - format!("{DEFAULT_COMMUNITY_REGISTRY}/base:latest") -} - -/// Resolve a user-supplied image string into a fully-qualified reference. -/// -/// Resolution rules (applied in order): -/// 1. If the value contains `/`, `:`, or `.` it is treated as a complete image -/// reference and returned as-is. -/// 2. Otherwise it is treated as a community sandbox name and expanded to -/// `{registry}/{value}:latest` where `{registry}` defaults to -/// [`DEFAULT_COMMUNITY_REGISTRY`] but can be overridden via the -/// `OPENSHELL_COMMUNITY_REGISTRY` environment variable. -/// -/// This function only handles image-name resolution. Dockerfile detection is -/// the responsibility of the caller (e.g. the CLI's `resolve_from()`). -pub fn resolve_community_image(value: &str) -> String { - // Already a fully-qualified reference. - if value.contains('/') || value.contains(':') || value.contains('.') { - return value.to_string(); - } - - // Community sandbox shorthand → expand with registry prefix. - let prefix = std::env::var("OPENSHELL_COMMUNITY_REGISTRY") - .unwrap_or_else(|_| DEFAULT_COMMUNITY_REGISTRY.to_string()); - let prefix = prefix.trim_end_matches('/'); - format!("{prefix}/{value}:latest") + DEFAULT_SANDBOX_BASE_IMAGE.to_string() } #[cfg(test)] -#[allow(unsafe_code)] mod tests { use super::*; - use std::sync::{Mutex, OnceLock}; - - fn env_lock() -> &'static Mutex<()> { - static ENV_LOCK: OnceLock> = OnceLock::new(); - ENV_LOCK.get_or_init(|| Mutex::new(())) - } #[test] - fn bare_name_expands_to_community_registry() { - let _guard = env_lock().lock().unwrap(); - let result = resolve_community_image("base"); - assert_eq!( - result, - "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" - ); - } - - #[test] - fn bare_name_with_env_override() { - let _guard = env_lock().lock().unwrap(); - // Use a temp env override. Safety: test-only, and these env-var tests - // are not run concurrently with other tests reading the same var. - let key = "OPENSHELL_COMMUNITY_REGISTRY"; - let prev = std::env::var(key).ok(); - // SAFETY: single-threaded test context; no other thread reads this var. - unsafe { std::env::set_var(key, "my-registry.example.com/sandboxes") }; - let result = resolve_community_image("python"); - assert_eq!(result, "my-registry.example.com/sandboxes/python:latest"); - // Restore. - match prev { - Some(v) => unsafe { std::env::set_var(key, v) }, - None => unsafe { std::env::remove_var(key) }, - } - } - - #[test] - fn full_reference_with_slash_passes_through() { - let _guard = env_lock().lock().unwrap(); - let input = "ghcr.io/myorg/myimage:v1"; - assert_eq!(resolve_community_image(input), input); - } - - #[test] - fn reference_with_colon_passes_through() { - let _guard = env_lock().lock().unwrap(); - let input = "myimage:latest"; - assert_eq!(resolve_community_image(input), input); - } - - #[test] - fn reference_with_dot_passes_through() { - let _guard = env_lock().lock().unwrap(); - let input = "registry.example.com"; - assert_eq!(resolve_community_image(input), input); - } - - #[test] - fn trailing_slash_in_env_is_trimmed() { - let _guard = env_lock().lock().unwrap(); - let key = "OPENSHELL_COMMUNITY_REGISTRY"; - let prev = std::env::var(key).ok(); - // SAFETY: single-threaded test context; no other thread reads this var. - unsafe { std::env::set_var(key, "my-registry.example.com/sandboxes/") }; - let result = resolve_community_image("base"); - assert_eq!(result, "my-registry.example.com/sandboxes/base:latest"); - match prev { - Some(v) => unsafe { std::env::set_var(key, v) }, - None => unsafe { std::env::remove_var(key) }, - } + fn default_image_is_version_qualified_nvidia_ubuntu() { + assert_eq!(default_sandbox_image(), "nvcr.io/nvidia/base/ubuntu:24.04"); } } diff --git a/crates/openshell-core/src/sandbox_env.rs b/crates/openshell-core/src/sandbox_env.rs index c1c91822b..24640c790 100644 --- a/crates/openshell-core/src/sandbox_env.rs +++ b/crates/openshell-core/src/sandbox_env.rs @@ -243,6 +243,18 @@ pub const SANDBOX_UID: &str = "OPENSHELL_SANDBOX_UID"; /// supervisor drops privileges to a group other than the UID's primary group. pub const SANDBOX_GID: &str = "OPENSHELL_SANDBOX_GID"; +/// Default numeric UID assigned to a sandbox when the image declares no OCI +/// `USER` (for example, a minimal base image). +/// +/// Local container drivers (Docker, Podman) supply this in place of an empty +/// OCI declaration so the supervisor runs the sandbox as a synthesized non-root +/// account instead of rejecting the image, matching the numeric-identity +/// behavior of the Kubernetes and VM drivers. +pub const DEFAULT_SANDBOX_UID: u32 = 1000; + +/// Default numeric GID paired with [`DEFAULT_SANDBOX_UID`]. +pub const DEFAULT_SANDBOX_GID: u32 = 1000; + /// Raw OCI `Config.User` declaration from the immutable image selected by a /// local container driver. /// diff --git a/crates/openshell-core/src/shell.rs b/crates/openshell-core/src/shell.rs index d24a7bced..16d0b4de0 100644 --- a/crates/openshell-core/src/shell.rs +++ b/crates/openshell-core/src/shell.rs @@ -10,8 +10,8 @@ //! with an opaque `No such file or directory`. //! //! These helpers resolve a shell that actually exists in the current root -//! filesystem. They must run inside the sandbox (i.e. in the supervisor), not -//! on the gateway, because the answer depends on the sandbox image's contents. +//! filesystem. They must run inside the workload boundary, not in the external +//! supervisor or gateway, because the answer depends on the workload image. /// Preferred interactive shell when the image provides it. pub const BASH: &str = "/bin/bash"; @@ -62,13 +62,19 @@ pub fn is_executable(path: &str) -> bool { /// that footgun. #[must_use] pub fn detect_login_shell() -> String { + find_login_shell().unwrap_or_else(|| POSIX_SH.to_string()) +} + +/// Resolve an executable shell in the current root filesystem. +/// +/// Unlike [`detect_login_shell`], this reports absence explicitly so boundary +/// exec can return a useful error for shell-free images. +#[must_use] +pub fn find_login_shell() -> Option { SHELL_CANDIDATES .iter() .find(|candidate| is_executable(candidate)) - .map_or_else( - || POSIX_SH.to_string(), - |candidate| (*candidate).to_string(), - ) + .map(|candidate| (*candidate).to_string()) } #[cfg(test)] diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index bd19d7f8c..9bef89f35 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -629,9 +629,18 @@ fn resolve_docker_identity_from_accounts( requested_user }; if user_selector.is_empty() { - return Err(Status::failed_precondition( - "the pinned image defaults to root; configure a non-root process.run_as_user", - )); + // The image declares no USER (for example, a minimal base image) and the policy + // requested none. Synthesize a numeric non-root identity instead of + // rejecting, matching the Podman driver's USER-less default and the + // numeric-identity behavior of the Kubernetes and VM drivers. + return ResolvedWorkloadIdentity::new( + openshell_core::sandbox_env::DEFAULT_SANDBOX_UID, + openshell_core::sandbox_env::DEFAULT_SANDBOX_GID, + Vec::new(), + "default".to_string(), + image.id.clone(), + ) + .map_err(|error| Status::failed_precondition(error.to_string())); } let (uid, passwd_entry) = resolve_numeric_or_named_user(user_selector, &passwd)?; let username = passwd_entry.map(|entry| entry.name.as_str()); diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index 4b0c9cef9..0908fc273 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -59,7 +59,7 @@ fn test_sandbox() -> DriverSandbox { log_level: "debug".to_string(), environment: HashMap::from([("SPEC_ENV".to_string(), "spec".to_string())]), template: Some(DriverSandboxTemplate { - image: "ghcr.io/nvidia/openshell-community/sandboxes/base:latest".to_string(), + image: "nvcr.io/nvidia/base/ubuntu:24.04".to_string(), agent_socket_path: String::new(), labels: HashMap::new(), environment: HashMap::from([("TEMPLATE_ENV".to_string(), "template".to_string())]), @@ -1334,6 +1334,33 @@ fn docker_identity_resolution_uses_pinned_image_accounts_and_exact_groups() { assert_eq!(resolved.resource_digest, "sha256:image"); } +#[test] +fn docker_identity_resolution_uses_numeric_default_for_userless_image() { + let image = DockerImageMetadata { + id: "sha256:image".to_string(), + user: String::new(), + working_dir: "/".to_string(), + volumes: Vec::new(), + }; + let resolved = resolve_docker_identity_from_accounts( + &test_sandbox(), + &image, + b"root:x:0:0:root:/root:/bin/sh\n", + b"root:x:0:\n", + ) + .unwrap(); + + assert_eq!( + (resolved.uid, resolved.gid), + ( + openshell_core::sandbox_env::DEFAULT_SANDBOX_UID, + openshell_core::sandbox_env::DEFAULT_SANDBOX_GID, + ) + ); + assert_eq!(resolved.source, "default"); + assert_eq!(resolved.resource_digest, "sha256:image"); +} + #[test] fn docker_identity_resolution_honors_policy_selectors_and_rejects_root() { let mut sandbox = test_sandbox(); diff --git a/crates/openshell-driver-podman/src/client.rs b/crates/openshell-driver-podman/src/client.rs index 445555101..8bf332d29 100644 --- a/crates/openshell-driver-podman/src/client.rs +++ b/crates/openshell-driver-podman/src/client.rs @@ -115,6 +115,10 @@ pub struct ContainerState { pub started_at: Option, #[serde(default)] pub finished_at: Option, + /// A driver-local diagnostic derived from a narrowly allow-listed + /// container-log marker. It is never deserialized from Podman. + #[serde(skip)] + pub startup_diagnostic: Option, } #[derive(Debug, Clone, serde::Deserialize)] @@ -643,6 +647,28 @@ impl PodmanClient { .await } + /// Read a bounded tail of a container's combined output. + /// + /// Callers must treat this as sensitive workload output. The Podman + /// watcher uses it only to recognize fixed, driver-owned startup markers; + /// it never forwards the raw output to the gateway. + pub async fn container_logs(&self, name: &str) -> Result { + validate_name(name)?; + let (status, bytes) = self + .request( + hyper::Method::GET, + &format!("/libpod/containers/{name}/logs?stdout=true&stderr=true&tail=200"), + None, + API_TIMEOUT, + ) + .await?; + if status.is_success() { + Ok(bytes) + } else { + Err(error_from_response(status.as_u16(), &bytes)) + } + } + /// List containers matching label filters (e.g. `&["openshell.managed=true"]`). pub async fn list_containers( &self, diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 572f741ac..e25c30a1f 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -593,18 +593,37 @@ fn build_env( // hostname could otherwise present a certificate for a name they control // and intercept the sandbox JWT. env.remove(openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME); - env.insert( - openshell_core::sandbox_env::OCI_IMAGE_USER.into(), - oci_user.to_string(), - ); - env.insert( - openshell_core::sandbox_env::SANDBOX_UID.into(), - String::new(), - ); - env.insert( - openshell_core::sandbox_env::SANDBOX_GID.into(), - String::new(), - ); + if oci_user.is_empty() { + // The image declares no OCI USER (for example, a minimal base image). Assign a + // numeric non-root identity like the Kubernetes and VM drivers so the + // supervisor synthesizes the account instead of rejecting the image. + env.insert( + openshell_core::sandbox_env::OCI_IMAGE_USER.into(), + String::new(), + ); + env.insert( + openshell_core::sandbox_env::SANDBOX_UID.into(), + openshell_core::sandbox_env::DEFAULT_SANDBOX_UID.to_string(), + ); + env.insert( + openshell_core::sandbox_env::SANDBOX_GID.into(), + openshell_core::sandbox_env::DEFAULT_SANDBOX_GID.to_string(), + ); + } else { + // The image declares a USER; preserve the OCI resolution path. + env.insert( + openshell_core::sandbox_env::OCI_IMAGE_USER.into(), + oci_user.to_string(), + ); + env.insert( + openshell_core::sandbox_env::SANDBOX_UID.into(), + String::new(), + ); + env.insert( + openshell_core::sandbox_env::SANDBOX_GID.into(), + String::new(), + ); + } // 4. Gateway-minted sandbox JWT. Keep the raw bearer out of container // metadata; the supervisor reads it from a driver-owned bind mount. @@ -1128,7 +1147,7 @@ fn build_base_spec( image_volumes, hostname: format!("sandbox-{}", sandbox.name), // Override the image's ENTRYPOINT so the supervisor binary runs - // directly. Sandbox images (e.g. the community base image) set + // directly. Workload images can set // ENTRYPOINT ["/bin/bash"], and Podman's `command` field only // overrides CMD — which gets appended as args to the entrypoint. // Without this, the container would run the entrypoint binary with @@ -1383,6 +1402,8 @@ pub struct IsolationSpecInput<'a> { pub supervisor_bin: Option<&'a Path>, pub tls_secrets: Option<&'a [String; 3]>, pub identity: &'a openshell_isolation_interface::contract::ResolvedWorkloadIdentity, + /// Whether this workload is created by a rootless Podman service. + pub rootless: bool, } pub struct IsolationSpecs { @@ -1420,19 +1441,44 @@ pub fn build_isolation_specs( .iter() .filter_map(|entry| entry.split_once('=').map(|(key, _)| key.to_string())) .collect(); - workload.command = vec![ - "--bootstrap".into(), - crate::isolation::BOOTSTRAP_PATH.into(), - ]; - workload.user.clone_from(&user); - workload.groups = input - .identity - .supplementary_gids - .iter() - .map(ToString::to_string) - .collect(); - workload.cap_drop = vec!["ALL".into()]; - workload.cap_add.clear(); + if input.rootless || input.identity.source == "default" { + // Podman's archive endpoint leaves named-volume contents owned by + // container root for rootless services and for a rootful USER-less + // image's newly-created workspace. Start the trusted runtime as root + // only long enough to chown the workspace, then irreversibly drop to + // the resolved workload identity before reading bootstrap material or + // accepting a control connection. + workload.command = vec![ + "launch-capability-free".into(), + input.identity.uid.to_string(), + input.identity.gid.to_string(), + crate::isolation::BOOTSTRAP_PATH.into(), + driver_mounts::DEFAULT_WORKSPACE_ROOT.into(), + ]; + workload.user = "0:0".into(); + workload.groups.clear(); + workload.cap_drop = vec!["ALL".into()]; + workload.cap_add = vec![ + "CHOWN".into(), + "SETGID".into(), + "SETUID".into(), + "SETPCAP".into(), + ]; + } else { + workload.command = vec![ + "--bootstrap".into(), + crate::isolation::BOOTSTRAP_PATH.into(), + ]; + workload.user.clone_from(&user); + workload.groups = input + .identity + .supplementary_gids + .iter() + .map(ToString::to_string) + .collect(); + workload.cap_drop = vec!["ALL".into()]; + workload.cap_add.clear(); + } workload.apparmor_profile = input .config .app_armor_profile @@ -1721,16 +1767,33 @@ mod tests { supervisor_bin: None, tls_secrets: None, identity: &identity, + rootless: true, }) .unwrap(); for spec in [&specs.workload, &specs.supervisor] { - assert_eq!(spec.user, "1000:1001"); - assert_eq!(spec.groups, vec!["2000"]); assert_eq!(spec.cap_drop, vec!["ALL"]); - assert!(spec.cap_add.is_empty()); assert!(spec.seccomp_profile_path.is_empty()); assert!(spec.no_new_privileges); } + assert_eq!(specs.workload.user, "0:0"); + assert!(specs.workload.groups.is_empty()); + assert_eq!( + specs.workload.cap_add, + vec!["CHOWN", "SETGID", "SETUID", "SETPCAP"] + ); + assert_eq!( + specs.workload.command, + vec![ + "launch-capability-free", + "1000", + "1001", + crate::isolation::BOOTSTRAP_PATH, + driver_mounts::DEFAULT_WORKSPACE_ROOT, + ] + ); + assert_eq!(specs.supervisor.user, "1000:1001"); + assert_eq!(specs.supervisor.groups, vec!["2000"]); + assert!(specs.supervisor.cap_add.is_empty()); assert_eq!(specs.workload.netns.nsmode, "none"); assert_eq!( specs @@ -1745,6 +1808,42 @@ mod tests { Some("openshell-sandbox") ); assert_eq!(specs.supervisor.apparmor_profile, None); + + let default_identity = + openshell_isolation_interface::contract::ResolvedWorkloadIdentity::new( + 1000, + 1000, + Vec::new(), + "default".into(), + "sha256:image".into(), + ) + .unwrap(); + let rootful_specs = build_isolation_specs(IsolationSpecInput { + sandbox: &sandbox, + config: &config, + token_secret: Some("jwt"), + gpu_devices: None, + requested_image: "image:latest", + image_id: "sha256:image", + image_user: "", + image_env: &env, + supervisor_bin: None, + tls_secrets: None, + identity: &default_identity, + rootless: false, + }) + .unwrap(); + assert_eq!(rootful_specs.workload.user, "0:0"); + assert_eq!( + rootful_specs.workload.command, + vec![ + "launch-capability-free", + "1000", + "1000", + crate::isolation::BOOTSTRAP_PATH, + driver_mounts::DEFAULT_WORKSPACE_ROOT, + ] + ); let workload_json = serde_json::to_string(&specs.workload).unwrap(); assert!(workload_json.contains("\"apparmor_profile\":\"openshell-sandbox\"")); assert_eq!(specs.supervisor.healthconfig.test, vec!["NONE"]); diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index de3bf3c95..2c9303f1c 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -944,6 +944,7 @@ impl PodmanComputeDriver { supervisor_bin: supervisor_bin_path.as_deref(), tls_secrets: tls_secret_names.as_ref(), identity: &identity, + rootless: self.rootless, }); let specs = match specs { Ok(spec) => spec, diff --git a/crates/openshell-driver-podman/src/isolation.rs b/crates/openshell-driver-podman/src/isolation.rs index 69679661f..36b9be92f 100644 --- a/crates/openshell-driver-podman/src/isolation.rs +++ b/crates/openshell-driver-podman/src/isolation.rs @@ -123,6 +123,19 @@ pub fn resolve_identity( } else { requested_group }; + if user.is_empty() && group.is_empty() { + // The image declares no OCI USER (for example, a minimal base image) and the + // policy requested no identity. Synthesize a numeric non-root identity + // instead of rejecting the image, matching Docker, Kubernetes, and VM. + return ResolvedWorkloadIdentity::new( + openshell_core::sandbox_env::DEFAULT_SANDBOX_UID, + openshell_core::sandbox_env::DEFAULT_SANDBOX_GID, + Vec::new(), + "default".into(), + image_id.into(), + ) + .map_err(invalid); + } let account = accounts .iter() .find(|(name, uid, _)| *name == user || user.parse::().ok() == Some(*uid)); @@ -423,10 +436,30 @@ mod tests { assert_eq!(identity.supplementary_gids, vec![2000]); assert_eq!(identity.resource_digest, "sha256:pinned"); assert!(resolve_identity(&sandbox, "sha256:pinned", "root", passwd, groups).is_err()); - assert!(resolve_identity(&sandbox, "sha256:pinned", "", passwd, groups).is_err()); assert!(resolve_identity(&sandbox, "sha256:pinned", "2000", passwd, groups).is_err()); } + #[test] + fn identity_uses_numeric_default_for_userless_image() { + let identity = resolve_identity( + &DriverSandbox::default(), + "sha256:pinned", + "", + b"root:x:0:0:root:/root:/bin/sh\n", + b"root:x:0:\n", + ) + .unwrap(); + + assert_eq!( + (identity.uid, identity.gid), + ( + openshell_core::sandbox_env::DEFAULT_SANDBOX_UID, + openshell_core::sandbox_env::DEFAULT_SANDBOX_GID, + ) + ); + assert_eq!(identity.source, "default"); + } + fn files(bytes: &[u8]) -> BTreeMap> { tar::Archive::new(bytes) .entries() diff --git a/crates/openshell-driver-podman/src/watcher.rs b/crates/openshell-driver-podman/src/watcher.rs index 56ab14706..ff0d0b12e 100644 --- a/crates/openshell-driver-podman/src/watcher.rs +++ b/crates/openshell-driver-podman/src/watcher.rs @@ -396,10 +396,30 @@ pub async fn inspect_workload( } Err(error) => return Err(error), } + } else if matches!(workload.state.status.as_str(), "exited" | "stopped") { + workload.state.startup_diagnostic = client + .container_logs(&workload.id) + .await + .ok() + .and_then(|logs| boundary_startup_termination_marker(&logs)); } Ok(workload) } +/// Extract only fixed, OpenShell-owned startup diagnostics from container +/// output. Workload and supervisor output may contain secrets, so it must not +/// be propagated to driver conditions or tracing. +fn boundary_startup_termination_marker(logs: &[u8]) -> Option { + const SIGTERM_MARKER: &str = "sandbox boundary received SIGTERM before supervisor confirmation"; + const SIGINT_MARKER: &str = "sandbox boundary received SIGINT before supervisor confirmation"; + + let logs = String::from_utf8_lossy(logs); + [SIGTERM_MARKER, SIGINT_MARKER] + .into_iter() + .find(|marker| logs.contains(marker)) + .map(str::to_string) +} + /// Construct a `DriverSandbox` from common fields. /// /// Centralises the boilerplate that every event/inspect/list path shares: @@ -528,7 +548,7 @@ fn condition_from_state(state: &ContainerState) -> DriverCondition { // exiting on its own — the signature of a machine/daemon restart // killing running containers. Those are recoverable at gateway // startup; ordinary application exits (0, non-zero, faults) are not. - let (reason, msg) = if state.oom_killed { + let (reason, mut msg) = if state.oom_killed { ( "OOMKilled", "Container was killed by the OOM killer".to_string(), @@ -552,6 +572,10 @@ fn condition_from_state(state: &ContainerState) -> DriverCondition { format!("Container exited with code {}", state.exit_code), ) }; + if let Some(diagnostic) = &state.startup_diagnostic { + msg.push_str(": "); + msg.push_str(diagnostic); + } ("False", reason, msg) } other => ( @@ -668,6 +692,7 @@ mod tests { health: None, started_at: Some("2026-08-12T16:38:58Z".to_string()), finished_at: Some("2026-08-12T16:39:13Z".to_string()), + startup_diagnostic: None, }; assert!(fences.matches_previous_exit( @@ -716,6 +741,7 @@ mod tests { }), started_at: Some("2026-04-14T10:00:00Z".to_string()), finished_at: None, + startup_diagnostic: None, }; let cond = condition_from_state(&state); assert_eq!(cond.r#type, "Ready"); @@ -734,6 +760,7 @@ mod tests { health: None, started_at: Some("2026-04-14T10:00:00Z".to_string()), finished_at: None, + startup_diagnostic: None, }; let cond = condition_from_state(&state); assert_eq!(cond.r#type, "Ready"); @@ -755,6 +782,7 @@ mod tests { }), started_at: Some("2026-04-14T10:00:00Z".to_string()), finished_at: None, + startup_diagnostic: None, }; let condition = condition_from_state(&state); assert_eq!(condition.r#type, "Ready"); @@ -772,6 +800,7 @@ mod tests { health: None, started_at: None, finished_at: Some("2026-04-14T11:00:00Z".to_string()), + startup_diagnostic: None, }; let cond = condition_from_state(&state); assert_eq!(cond.status, "False"); @@ -789,6 +818,7 @@ mod tests { health: None, started_at: None, finished_at: Some("2026-04-14T12:00:00Z".to_string()), + startup_diagnostic: None, }; let cond = condition_from_state(&state); assert_eq!(cond.status, "False"); @@ -796,6 +826,38 @@ mod tests { assert!(cond.message.contains("code 1")); } + #[test] + fn condition_includes_allow_listed_boundary_startup_diagnostic() { + let state = ContainerState { + status: "exited".to_string(), + running: false, + exit_code: 1, + oom_killed: false, + health: None, + started_at: None, + finished_at: Some("2026-04-14T12:00:00Z".to_string()), + startup_diagnostic: boundary_startup_termination_marker( + b"untrusted workload output\nsandbox boundary received SIGTERM before supervisor confirmation\n", + ), + }; + + let condition = condition_from_state(&state); + + assert_eq!(condition.reason, CONDITION_EXITED); + assert_eq!( + condition.message, + "Container exited with code 1: sandbox boundary received SIGTERM before supervisor confirmation" + ); + } + + #[test] + fn boundary_startup_diagnostic_does_not_forward_unrecognized_logs() { + assert_eq!( + boundary_startup_termination_marker(b"token=not-for-the-driver"), + None + ); + } + #[test] fn condition_workspace_validation_exit_is_reported_explicitly() { let state = ContainerState { @@ -806,6 +868,7 @@ mod tests { health: None, started_at: None, finished_at: Some("2026-04-14T12:00:00Z".to_string()), + startup_diagnostic: None, }; let cond = condition_from_state(&state); @@ -829,6 +892,7 @@ mod tests { health: None, started_at: None, finished_at: Some("2026-04-14T12:30:00Z".to_string()), + startup_diagnostic: None, }; let cond = condition_from_state(&state); assert_eq!(cond.status, "False"); diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index fbd4230de..68f1de47c 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -576,6 +576,35 @@ struct SandboxRecord { deleting: bool, } +/// Resolve a lifecycle request to a registry key. +/// +/// A non-empty `sandbox_id` is authoritative: resolution uses that id alone and +/// never falls back to the name, so a request for an already-removed sandbox +/// reports absence instead of matching a same-named sandbox in another +/// workspace. Only a caller that supplies no id resolves by name, and because +/// sandbox names are unique per workspace rather than globally, a name matching +/// more than one record is rejected instead of decided by iteration order. +fn resolve_record_id( + registry: &HashMap, + sandbox_id: &str, + sandbox_name: &str, +) -> Result, Status> { + if !sandbox_id.is_empty() { + return Ok(registry.get_key_value(sandbox_id).map(|(id, _)| id.clone())); + } + + let mut matches = registry + .iter() + .filter(|(_, record)| record.snapshot.name == sandbox_name); + let first = matches.next().map(|(id, _)| id.clone()); + if matches.next().is_some() { + return Err(Status::failed_precondition(format!( + "sandbox_name {sandbox_name} matched more than one sandbox; supply sandbox_id" + ))); + } + Ok(first) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum OverlayPreparation { Fresh, @@ -1707,14 +1736,7 @@ impl VmDriver { } let record_id = { let registry = self.registry.lock().await; - if registry.contains_key(sandbox_id) { - Some(sandbox_id.to_string()) - } else { - registry - .iter() - .find(|(_, record)| record.snapshot.name == sandbox_name) - .map(|(id, _)| id.clone()) - } + resolve_record_id(®istry, sandbox_id, sandbox_name)? } .ok_or_else(|| Status::not_found("sandbox not found"))?; @@ -1799,16 +1821,13 @@ impl VmDriver { .map_err(|error| Status::invalid_argument(error.to_string()))?; let (record_id, state_dir, already_running) = { let registry = self.registry.lock().await; - let (id, record) = if let Some(entry) = registry.get_key_value(sandbox_id) { - entry - } else { - registry - .iter() - .find(|(_, record)| record.snapshot.name == sandbox_name) - .ok_or_else(|| Status::not_found("sandbox not found"))? - }; + let id = resolve_record_id(®istry, sandbox_id, sandbox_name)? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + let record = registry + .get(&id) + .ok_or_else(|| Status::not_found("sandbox not found"))?; ( - id.clone(), + id, record.state_dir.clone(), record.process.is_some() || record.provisioning_task.is_some(), ) @@ -1921,14 +1940,7 @@ impl VmDriver { let record_id = { let registry = self.registry.lock().await; - if let Some((id, _record)) = registry.get_key_value(sandbox_id) { - Some(id.clone()) - } else { - registry - .iter() - .find(|(_, record)| record.snapshot.name == sandbox_name) - .map(|(id, _)| id.clone()) - } + resolve_record_id(®istry, sandbox_id, sandbox_name)? }; let Some(record_id) = record_id else { @@ -10718,4 +10730,198 @@ mod tests { assert!(gpu.default_selection_supported); assert!(gpu.count_selection_supported); } + + /// Register a stopped, process-free record whose id, name, and workspace are + /// set independently. + /// + /// The other test helpers reuse one string for both id and name, so a test + /// built on them cannot express the cross-workspace name collision that + /// lifecycle resolution has to tolerate. + async fn insert_named_record( + driver: &VmDriver, + id: &str, + name: &str, + workspace: &str, + ) -> PathBuf { + let state_dir = sandbox_state_dir(&driver.config.state_dir, id).unwrap(); + create_private_dir_all(&state_dir).await.unwrap(); + driver.registry.lock().await.insert( + id.to_string(), + SandboxRecord { + snapshot: Sandbox { + id: id.to_string(), + name: name.to_string(), + workspace: workspace.to_string(), + ..Default::default() + }, + state_dir: state_dir.clone(), + process: None, + provisioning_task: None, + gpu_bdf: None, + deleting: false, + }, + ); + state_dir + } + + fn resolution_test_driver(state_dir: &Path) -> VmDriver { + let mut driver = test_driver_with_extensions(LifecycleExtensionRegistry::new()); + driver.config.state_dir = state_dir.to_path_buf(); + driver + } + + #[tokio::test] + async fn stop_targets_the_requested_id_when_two_workspaces_share_a_name() { + let temp = tempfile::tempdir().unwrap(); + let driver = resolution_test_driver(temp.path()); + let alpha = insert_named_record(&driver, "vm-alpha", "demo", "alpha").await; + let beta = insert_named_record(&driver, "vm-beta", "demo", "beta").await; + + driver + .stop_sandbox("vm-beta", "demo") + .await + .expect("stop by id should be accepted"); + + assert!( + beta.join(SANDBOX_STOPPED_FILE).exists(), + "the requested sandbox should have been stopped" + ); + assert!( + !alpha.join(SANDBOX_STOPPED_FILE).exists(), + "the same-named sandbox in another workspace must be untouched" + ); + } + + #[tokio::test] + async fn delete_targets_the_requested_id_when_two_workspaces_share_a_name() { + let temp = tempfile::tempdir().unwrap(); + let driver = resolution_test_driver(temp.path()); + let alpha = insert_named_record(&driver, "vm-alpha", "demo", "alpha").await; + insert_named_record(&driver, "vm-beta", "demo", "beta").await; + + let response = driver + .delete_sandbox("vm-beta", "demo") + .await + .expect("delete by id should be accepted"); + + assert!(response.deleted); + let registry = driver.registry.lock().await; + assert!(!registry.contains_key("vm-beta")); + assert!( + registry.contains_key("vm-alpha"), + "the same-named sandbox in another workspace must survive" + ); + assert!(alpha.exists(), "the surviving sandbox must keep its state"); + } + + #[tokio::test] + async fn a_repeated_delete_does_not_fall_back_to_a_same_named_sandbox() { + let temp = tempfile::tempdir().unwrap(); + let driver = resolution_test_driver(temp.path()); + let alpha = insert_named_record(&driver, "vm-alpha", "demo", "alpha").await; + insert_named_record(&driver, "vm-beta", "demo", "beta").await; + + let first = driver + .delete_sandbox("vm-beta", "demo") + .await + .expect("the first delete should be accepted"); + assert!(first.deleted); + + // Delete is idempotent, so a retry carrying the same id and name is + // ordinary caller behavior. The id is gone from the registry by now, and + // resolving it by name instead would destroy the sandbox in `alpha`. + let second = driver + .delete_sandbox("vm-beta", "demo") + .await + .expect("a repeated delete should be accepted"); + + assert!( + !second.deleted, + "a repeated delete must report that nothing was removed" + ); + assert!( + driver.registry.lock().await.contains_key("vm-alpha"), + "a repeated delete must not remove a same-named sandbox in another workspace" + ); + assert!(alpha.exists(), "the surviving sandbox must keep its state"); + } + + #[tokio::test] + async fn stop_and_start_reject_an_absent_id_that_shares_a_name() { + let temp = tempfile::tempdir().unwrap(); + let driver = resolution_test_driver(temp.path()); + let alpha = insert_named_record(&driver, "vm-alpha", "demo", "alpha").await; + + let stop_error = driver + .stop_sandbox("vm-absent", "demo") + .await + .expect_err("a supplied id that is not registered must not resolve by name"); + assert_eq!(stop_error.code(), Code::NotFound); + + let (start_authentication, _) = test_launch_authentication("absent"); + let start_error = driver + .start_sandbox( + "vm-absent", + "demo", + "g0000000000000001", + start_authentication, + ) + .await + .expect_err("a supplied id that is not registered must not resolve by name"); + assert_eq!(start_error.code(), Code::NotFound); + + assert!( + !alpha.join(SANDBOX_STOPPED_FILE).exists(), + "the same-named sandbox must not have been touched" + ); + } + + #[tokio::test] + async fn a_name_only_request_matching_two_sandboxes_is_rejected() { + let temp = tempfile::tempdir().unwrap(); + let driver = resolution_test_driver(temp.path()); + let alpha = insert_named_record(&driver, "vm-alpha", "demo", "alpha").await; + let beta = insert_named_record(&driver, "vm-beta", "demo", "beta").await; + + // Without an id the driver has nothing to disambiguate with: the request + // carries no workspace, and picking by iteration order would stop or + // delete an arbitrary one of the two. + for error in [ + driver.stop_sandbox("", "demo").await.unwrap_err(), + driver + .start_sandbox( + "", + "demo", + "g0000000000000001", + test_launch_authentication("ambiguous").0, + ) + .await + .unwrap_err(), + driver.delete_sandbox("", "demo").await.unwrap_err(), + ] { + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("matched more than one sandbox")); + } + + let registry = driver.registry.lock().await; + assert!(registry.contains_key("vm-alpha")); + assert!(registry.contains_key("vm-beta")); + assert!(!alpha.join(SANDBOX_STOPPED_FILE).exists()); + assert!(!beta.join(SANDBOX_STOPPED_FILE).exists()); + } + + #[tokio::test] + async fn a_name_only_request_still_resolves_a_unique_name() { + let temp = tempfile::tempdir().unwrap(); + let driver = resolution_test_driver(temp.path()); + let alpha = insert_named_record(&driver, "vm-alpha", "demo", "alpha").await; + insert_named_record(&driver, "vm-beta", "other", "beta").await; + + driver + .stop_sandbox("", "demo") + .await + .expect("an unambiguous name-only stop should still resolve"); + + assert!(alpha.join(SANDBOX_STOPPED_FILE).exists()); + } } diff --git a/crates/openshell-gateway/src/vm.rs b/crates/openshell-gateway/src/vm.rs index ce8e28e6d..86b7e3758 100644 --- a/crates/openshell-gateway/src/vm.rs +++ b/crates/openshell-gateway/src/vm.rs @@ -58,7 +58,6 @@ use tower::service_fn; const DRIVER_BIN_NAME: &str = "openshell-driver-vm"; const COMPUTE_DRIVER_SOCKET_RUN_DIR: &str = "run"; const COMPUTE_DRIVER_SOCKET_NAME: &str = "compute-driver.sock"; -const DEFAULT_VM_SANDBOX_IMAGE: &str = "nvcr.io/nvidia/base/ubuntu:24.04"; /// Configuration for launching and talking to the VM compute driver. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -234,7 +233,7 @@ impl Default for VmComputeConfig { Self { state_dir: Self::default_state_dir(), driver_dir: None, - default_image: DEFAULT_VM_SANDBOX_IMAGE.to_string(), + default_image: openshell_core::image::default_sandbox_image(), grpc_endpoint: String::new(), bootstrap_image: String::new(), krun_log_level: Self::default_krun_log_level(), @@ -764,7 +763,7 @@ async fn connect_compute_driver(socket_path: &Path) -> Result { #[cfg(all(test, unix))] mod tests { use super::{ - DEFAULT_VM_SANDBOX_IMAGE, VmComputeConfig, append_otlp_args, append_vm_identity_args, + VmComputeConfig, append_otlp_args, append_vm_identity_args, append_vm_proxy_and_spiffe_args, append_vm_rootfs_tar_args, compute_driver_guest_tls_paths, compute_driver_socket_path, current_euid, prepare_compute_driver_socket_path, prepare_vm_state_dir, resolve_compute_driver_bin, resolve_driver_search_dirs, @@ -781,7 +780,7 @@ mod tests { fn vm_uses_nvidia_ubuntu_default_image() { assert_eq!( VmComputeConfig::default().default_image, - DEFAULT_VM_SANDBOX_IMAGE + openshell_core::image::DEFAULT_SANDBOX_BASE_IMAGE ); } diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index 163cb9100..cc559f5a7 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -725,6 +725,12 @@ pub struct ExecSpec { pub program: String, /// Program arguments. pub args: Vec, + /// Workload-local shell request. When present, the boundary resolves the + /// concrete shell and ignores `program` and `args`. + pub shell: Option, + /// Trusted helper implemented by the sandbox runtime. This intent is set + /// only by supervisor-owned protocol adapters, never by public exec APIs. + pub runtime_helper: Option, /// Extra environment over the boundary's base. pub env: Vec<(String, String)>, /// Working directory, if any. @@ -733,6 +739,21 @@ pub struct ExecSpec { pub pty: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeHelper { + Sftp, +} + +/// A shell invocation whose executable must be resolved inside the workload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShellSpec { + /// Command passed to the shell. `None` starts an interactive shell when a + /// PTY is requested and a plain shell otherwise. + pub command: Option, + /// Whether command execution should load the shell's login environment. + pub login: bool, +} + /// In-boundary process entry, consumed by the SSH server and supervisor session. /// /// Like `start_agent`, every exec ensures the applicable launch-time controls diff --git a/crates/openshell-isolation-interface/tests/backend_conformance.rs b/crates/openshell-isolation-interface/tests/backend_conformance.rs index 37abe4cd7..09166e01e 100644 --- a/crates/openshell-isolation-interface/tests/backend_conformance.rs +++ b/crates/openshell-isolation-interface/tests/backend_conformance.rs @@ -702,6 +702,8 @@ async fn exec_session_owns_its_process_and_streams() { .exec(ExecSpec { program: "/bin/sh".to_string(), args: vec!["-c".to_string(), "true".to_string()], + shell: None, + runtime_helper: None, env: vec![], workdir: None, pty: false, @@ -723,6 +725,8 @@ async fn pty_exec_merges_output_and_supports_resize() { .exec(ExecSpec { program: "/bin/sh".to_string(), args: vec![], + shell: None, + runtime_helper: None, env: vec![], workdir: None, pty: true, diff --git a/crates/openshell-ocsf/tests/roundtrip.rs b/crates/openshell-ocsf/tests/roundtrip.rs index 42664400a..4617cbfe2 100644 --- a/crates/openshell-ocsf/tests/roundtrip.rs +++ b/crates/openshell-ocsf/tests/roundtrip.rs @@ -22,7 +22,7 @@ fn ctx() -> EventContext { EventContext { sandbox_id: "sb-7f3a9c2e14b8".to_string(), sandbox_name: "agent-workspace-01".to_string(), - container_image: "ghcr.io/nvidia/openshell-community/sandboxes/base:latest".to_string(), + container_image: "nvcr.io/nvidia/base/ubuntu:24.04".to_string(), hostname: "openshell-sb-7f3a9c2e14b8".to_string(), product_version: "0.42.1".to_string(), proxy_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index 5443883ac..f6ba71fed 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -970,7 +970,7 @@ pub use openshell_core::container_paths::CONTAINER_POLICY_PATH; /// Legacy path used before the navigator → openshell rename. /// -/// Existing community sandbox images still ship their policy at this path. +/// Older images may still ship their policy at this path. /// The sandbox supervisor tries [`CONTAINER_POLICY_PATH`] first, then falls /// back to this legacy path for backward compatibility. pub const LEGACY_CONTAINER_POLICY_PATH: &str = "/etc/navigator/policy.yaml"; @@ -988,11 +988,11 @@ pub fn restrictive_default_policy() -> SandboxPolicy { filesystem: Some(FilesystemPolicy { include_workdir: true, read_only: vec![ + "/bin".into(), "/usr".into(), "/lib".into(), "/proc".into(), "/dev/urandom".into(), - "/app".into(), "/etc".into(), "/var/log".into(), ], @@ -2128,6 +2128,10 @@ network_policies: let policy = restrictive_default_policy(); let fs = policy.filesystem.expect("must have filesystem policy"); assert!(fs.include_workdir); + assert!( + fs.read_only.iter().any(|p| p == "/bin"), + "read_only should contain /bin" + ); assert!( fs.read_only.iter().any(|p| p == "/usr"), "read_only should contain /usr" diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index c70605662..2c56fd1ed 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -25,7 +25,7 @@ use openshell_isolation_interface::contract::Sha256Digest; use openshell_isolation_interface::contract::{ BackendDescriptor, BackendError, BinaryIdentity, BoundaryConfirmation, BoundaryExitStatus, BoundaryProperties, BoundarySignal, EnforcedProperty, ExecSpec, OuterFenceGuarantees, - ResolveError, + ResolveError, ShellSpec, }; use rcgen::{CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose}; use serde::de::DeserializeOwned; @@ -1006,16 +1006,56 @@ impl BinaryIdentityWire { pub struct ExecSpecWire { pub program: String, pub args: Vec, + #[serde(default)] + pub shell: Option, + #[serde(default)] + pub runtime_helper: Option, pub env: Vec<(String, String)>, pub workdir: Option, pub pty: bool, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeHelperWire { + Sftp, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ShellSpecWire { + pub command: Option, + pub login: bool, +} + +impl From for ShellSpecWire { + fn from(spec: ShellSpec) -> Self { + Self { + command: spec.command, + login: spec.login, + } + } +} + +impl From for ShellSpec { + fn from(spec: ShellSpecWire) -> Self { + Self { + command: spec.command, + login: spec.login, + } + } +} + impl From for ExecSpecWire { fn from(spec: ExecSpec) -> Self { Self { program: spec.program, args: spec.args, + shell: spec.shell.map(ShellSpecWire::from), + runtime_helper: spec.runtime_helper.map(|helper| match helper { + openshell_isolation_interface::contract::RuntimeHelper::Sftp => { + RuntimeHelperWire::Sftp + } + }), env: spec.env, workdir: spec.workdir, pty: spec.pty, @@ -1028,6 +1068,12 @@ impl From for ExecSpec { Self { program: spec.program, args: spec.args, + shell: spec.shell.map(ShellSpec::from), + runtime_helper: spec.runtime_helper.map(|helper| match helper { + RuntimeHelperWire::Sftp => { + openshell_isolation_interface::contract::RuntimeHelper::Sftp + } + }), env: spec.env, workdir: spec.workdir, pty: spec.pty, @@ -1591,4 +1637,34 @@ mod tests { let decoded: SandboxTransport = serde_json::from_slice(&encoded).expect("decode transport"); assert_eq!(decoded, transport); } + + #[test] + fn exec_wire_accepts_legacy_direct_exec_without_shell_intent() { + let wire: ExecSpecWire = serde_json::from_str( + r#"{"program":"/bin/true","args":[],"env":[],"workdir":null,"pty":false}"#, + ) + .expect("decode legacy exec spec"); + + assert_eq!(wire.shell, None); + assert_eq!(wire.runtime_helper, None); + } + + #[test] + fn exec_wire_preserves_trusted_runtime_helper_intent() { + let spec = ExecSpec { + program: String::new(), + args: Vec::new(), + shell: None, + runtime_helper: Some(openshell_isolation_interface::contract::RuntimeHelper::Sftp), + env: Vec::new(), + workdir: None, + pty: false, + }; + + let decoded = ExecSpec::from(ExecSpecWire::from(spec)); + assert_eq!( + decoded.runtime_helper, + Some(openshell_isolation_interface::contract::RuntimeHelper::Sftp) + ); + } } diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index 16840ca57..06891d8aa 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -2902,6 +2902,8 @@ mod tests { ExecSpec { program: "/bin/true".to_string(), args: Vec::new(), + shell: None, + runtime_helper: None, env: Vec::new(), workdir: None, pty: false, diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 1283dd063..da8ddd45a 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -69,6 +69,8 @@ tracing-subscriber = { workspace = true } [target.'cfg(unix)'.dependencies] libc = "0.2" rustix = { workspace = true } +russh-sftp = "3.0" +uuid = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] capctl = "0.2.4" diff --git a/crates/openshell-sandbox/src/boundary_exec.rs b/crates/openshell-sandbox/src/boundary_exec.rs index 57d9778a6..f4f5a13f3 100644 --- a/crates/openshell-sandbox/src/boundary_exec.rs +++ b/crates/openshell-sandbox/src/boundary_exec.rs @@ -4,7 +4,13 @@ //! Workload-side implementation of RFC 0012 sandbox exec. use std::collections::HashMap; +#[cfg(target_os = "linux")] +use std::io::Write as _; +#[cfg(target_os = "linux")] +use std::net::Shutdown; use std::os::fd::{AsRawFd, OwnedFd}; +#[cfg(target_os = "linux")] +use std::os::unix::net::UnixStream as StdUnixStream; use std::process::{Child, Command, Stdio}; use std::sync::Arc; @@ -59,12 +65,93 @@ impl LocalBoundaryExec { } } + #[cfg(target_os = "linux")] + fn runtime_helper(&self, spec: ExecSpec) -> Result { + self.runtime.ensure_active()?; + if spec.pty + || spec.runtime_helper + != Some(openshell_isolation_interface::contract::RuntimeHelper::Sftp) + { + return Err(BackendError::Process( + "unsupported sandbox runtime helper request".to_string(), + )); + } + let root = spec + .workdir + .as_deref() + .or(self.base_workdir.as_deref()) + .map(std::path::PathBuf::from) + .ok_or_else(|| { + BackendError::Process("SFTP requires a workload directory".to_string()) + })?; + + let (stdin_client, stdin_helper) = helper_socket_pair()?; + let (stdout_client, stdout_helper) = helper_socket_pair()?; + let (stderr_client, mut stderr_helper) = helper_socket_pair()?; + let cancel_sockets = vec![ + stdin_helper.try_clone().map_err(|error| { + BackendError::Process(format!("clone runtime helper input: {error}")) + })?, + stdout_helper.try_clone().map_err(|error| { + BackendError::Process(format!("clone runtime helper output: {error}")) + })?, + ]; + let stdin = async_socket(stdin_client)?.into_split().1; + let stdout = async_socket(stdout_client)?.into_split().0; + let stderr = async_socket(stderr_client)?.into_split().0; + let input = async_socket(stdin_helper)?; + let output = async_socket(stdout_helper)?; + let worker = tokio::spawn(async move { + crate::sftp::serve(tokio::io::join(input, output), root) + .await + .map_err(|error| { + let message = format!("{error:?}"); + let _ = writeln!(stderr_helper, "{message}"); + message + }) + }); + let process = RuntimeHelperProcess::new(worker, cancel_sockets); + Ok(ExecSession { + process, + stdin: Some(Box::new(stdin)), + stdout: Box::new(stdout), + stderr: Some(Box::new(stderr)), + terminal: None, + }) + } + fn command(&self, spec: &ExecSpec) -> Result { - if spec.program.is_empty() { + let (program, args) = if let Some(shell_spec) = &spec.shell { + let shell = openshell_core::shell::find_login_shell().ok_or_else(|| { + BackendError::Process( + "sandbox image does not provide an executable shell at /bin/bash, /usr/bin/bash, or /bin/sh" + .to_string(), + ) + })?; + let args = shell_spec.command.as_ref().map_or_else( + || { + if spec.pty { + vec!["-i".to_string()] + } else { + Vec::new() + } + }, + |command| { + vec![ + if shell_spec.login { "-lc" } else { "-c" }.to_string(), + command.clone(), + ] + }, + ); + (shell, args) + } else { + (spec.program.clone(), spec.args.clone()) + }; + if program.is_empty() { return Err(BackendError::Process("exec program is empty".to_string())); } - let mut command = Command::new(&spec.program); - command.args(&spec.args); + let mut command = Command::new(&program); + command.args(&args); let effective_workdir = spec.workdir.as_deref().or(self.base_workdir.as_deref()); let (session_user, session_home) = crate::process::session_user_and_home(&self.policy, effective_workdir); @@ -74,9 +161,11 @@ impl LocalBoundaryExec { .env(openshell_core::sandbox_env::SANDBOX, "1") .env("HOME", session_home) .env("USER", session_user) - .env("SHELL", "/bin/bash") .env("PATH", path) .env("TERM", if spec.pty { "xterm-256color" } else { "dumb" }); + if let Some(shell) = openshell_core::shell::find_login_shell() { + command.env("SHELL", shell); + } for (key, value) in &self.user_environment { if !key.starts_with("OPENSHELL_") { command.env(key, value); @@ -328,6 +417,10 @@ impl Drop for SpawnedExec { #[async_trait] impl BoundaryExec for LocalBoundaryExec { async fn exec(&self, spec: ExecSpec) -> Result { + #[cfg(target_os = "linux")] + if spec.runtime_helper.is_some() { + return self.runtime_helper(spec); + } let executor = self.clone(); let (send, receive) = tokio::sync::oneshot::channel(); tokio::task::spawn_blocking(move || { @@ -348,6 +441,88 @@ impl BoundaryExec for LocalBoundaryExec { } } +#[cfg(target_os = "linux")] +fn helper_socket_pair() -> Result<(StdUnixStream, StdUnixStream), BackendError> { + StdUnixStream::pair() + .map_err(|error| BackendError::Process(format!("create runtime helper pipe: {error}"))) +} + +#[cfg(target_os = "linux")] +fn async_socket(socket: StdUnixStream) -> Result { + socket.set_nonblocking(true).map_err(|error| { + BackendError::Process(format!("configure runtime helper pipe: {error}")) + })?; + tokio::net::UnixStream::from_std(socket) + .map_err(|error| BackendError::Process(format!("adopt runtime helper pipe: {error}"))) +} + +#[cfg(target_os = "linux")] +struct RuntimeHelperProcess { + result: Arc>>, + exited: Arc, + abort: tokio::task::AbortHandle, + cancel_sockets: Vec, +} + +#[cfg(target_os = "linux")] +impl RuntimeHelperProcess { + fn new( + worker: tokio::task::JoinHandle>, + cancel_sockets: Vec, + ) -> Arc { + let result = Arc::new(std::sync::Mutex::new(None)); + let exited = Arc::new(tokio::sync::Notify::new()); + let process = Arc::new(Self { + result: result.clone(), + exited: exited.clone(), + abort: worker.abort_handle(), + cancel_sockets, + }); + tokio::spawn(async move { + let status = match worker.await { + Ok(Ok(())) => BoundaryExitStatus::Exited(0), + Err(error) if error.is_cancelled() => BoundaryExitStatus::Signaled(9), + Ok(Err(_)) | Err(_) => BoundaryExitStatus::Exited(1), + }; + if let Ok(mut slot) = result.lock() { + *slot = Some(status); + } + exited.notify_waiters(); + }); + process + } +} + +#[cfg(target_os = "linux")] +#[async_trait] +impl BoundaryProcess for RuntimeHelperProcess { + async fn wait(&self) -> Result { + loop { + let notified = self.exited.notified(); + let status = *self + .result + .lock() + .map_err(|_| BackendError::Process("runtime helper result lock poisoned".into()))?; + if let Some(status) = status { + return Ok(status); + } + notified.await; + } + } + + async fn signal(&self, _signal: BoundarySignal) -> Result<(), BackendError> { + for socket in &self.cancel_sockets { + let _ = socket.shutdown(Shutdown::Both); + } + self.abort.abort(); + Ok(()) + } + + async fn terminate(&self) -> Result<(), BackendError> { + self.signal(BoundarySignal::Kill).await + } +} + struct LocalTerminal { master: std::fs::File, } @@ -535,6 +710,57 @@ mod tests { ) } + #[test] + fn shell_requests_are_resolved_by_the_workload_executor() { + let executor = executor(); + let command = executor + .command(&ExecSpec { + program: "/supervisor/does/not/share/this/root".to_string(), + args: vec!["ignored".to_string()], + shell: Some(openshell_isolation_interface::contract::ShellSpec { + command: Some("printf ready".to_string()), + login: true, + }), + runtime_helper: None, + env: vec![], + workdir: None, + pty: false, + }) + .expect("resolve workload shell"); + + assert_eq!( + command.get_program(), + std::ffi::OsStr::new(&openshell_core::shell::detect_login_shell()) + ); + assert_eq!( + command.get_args().collect::>(), + [ + std::ffi::OsStr::new("-lc"), + std::ffi::OsStr::new("printf ready") + ] + ); + } + + #[test] + fn interactive_shell_request_adds_interactive_flag() { + let command = executor() + .command(&ExecSpec { + program: String::new(), + args: Vec::new(), + shell: Some(openshell_isolation_interface::contract::ShellSpec { + command: None, + login: true, + }), + runtime_helper: None, + env: vec![], + workdir: None, + pty: true, + }) + .expect("resolve interactive workload shell"); + + assert_eq!(command.get_args().collect::>(), ["-i"]); + } + #[tokio::test] async fn non_pty_exec_preserves_stdin_stdout_and_stderr() { let mut session = executor() @@ -545,6 +771,8 @@ mod tests { "read line; printf 'out:%s' \"$line\"; printf 'err:%s' \"$line\" >&2" .to_string(), ], + shell: None, + runtime_helper: None, env: vec![], workdir: None, pty: false, @@ -576,6 +804,92 @@ mod tests { assert_eq!(stderr, "err:value"); } + #[tokio::test] + async fn trusted_sftp_helper_round_trips_through_boundary_streams() { + let root = tempfile::tempdir().unwrap(); + let mut executor = executor(); + executor.base_workdir = Some(root.path().to_string_lossy().into_owned()); + let mut session = executor + .exec(ExecSpec { + program: String::new(), + args: Vec::new(), + shell: None, + runtime_helper: Some(openshell_isolation_interface::contract::RuntimeHelper::Sftp), + env: Vec::new(), + workdir: None, + pty: false, + }) + .await + .expect("start SFTP helper"); + let process = session.process.clone(); + let stream = tokio::io::join( + session.stdout, + session.stdin.take().expect("SFTP input stream"), + ); + let client = russh_sftp::client::SftpSession::new(stream) + .await + .expect("start SFTP client"); + let mut file = client.create("boundary.txt").await.expect("create file"); + file.write_all(b"through boundary") + .await + .expect("write file"); + drop(file); + drop(client); + assert_eq!( + tokio::time::timeout(std::time::Duration::from_secs(2), process.wait()) + .await + .expect("SFTP helper exits") + .expect("SFTP helper status"), + BoundaryExitStatus::Exited(0) + ); + assert_eq!( + std::fs::read(root.path().join("boundary.txt")).unwrap(), + b"through boundary" + ); + } + + #[tokio::test] + async fn cancelling_sftp_helper_closes_protocol_stream() { + let root = tempfile::tempdir().unwrap(); + let mut executor = executor(); + executor.base_workdir = Some(root.path().to_string_lossy().into_owned()); + let mut session = executor + .exec(ExecSpec { + program: String::new(), + args: Vec::new(), + shell: None, + runtime_helper: Some(openshell_isolation_interface::contract::RuntimeHelper::Sftp), + env: Vec::new(), + workdir: None, + pty: false, + }) + .await + .expect("start SFTP helper"); + let process = session.process.clone(); + let stream = tokio::io::join( + session.stdout, + session.stdin.take().expect("SFTP input stream"), + ); + let client = russh_sftp::client::SftpSession::new(stream) + .await + .expect("start SFTP client"); + + process.terminate().await.expect("cancel SFTP helper"); + assert_eq!( + tokio::time::timeout(std::time::Duration::from_secs(2), process.wait()) + .await + .expect("SFTP helper exits") + .expect("SFTP helper status"), + BoundaryExitStatus::Signaled(9) + ); + assert!( + tokio::time::timeout(std::time::Duration::from_secs(2), client.metadata(".")) + .await + .expect("SFTP client observes cancellation") + .is_err() + ); + } + #[tokio::test] async fn exec_rejects_after_boundary_end() { let executor = executor(); @@ -584,6 +898,8 @@ mod tests { .exec(ExecSpec { program: "/bin/sh".to_string(), args: vec!["-c".to_string(), "exit 0".to_string()], + shell: None, + runtime_helper: None, env: vec![], workdir: None, pty: false, @@ -600,6 +916,8 @@ mod tests { .exec(ExecSpec { program: "/definitely/missing/openshell-exec".to_string(), args: vec![], + shell: None, + runtime_helper: None, env: vec![], workdir: None, pty: false, @@ -619,6 +937,8 @@ mod tests { .exec(ExecSpec { program: "/bin/sleep".to_string(), args: vec!["30".to_string()], + shell: None, + runtime_helper: None, env: vec![], workdir: None, pty: false, @@ -650,6 +970,8 @@ mod tests { executor.spawn_piped(&ExecSpec { program: "/bin/sleep".to_string(), args: vec!["30".to_string()], + shell: None, + runtime_helper: None, env: vec![], workdir: None, pty: false, @@ -681,6 +1003,8 @@ mod tests { .exec(ExecSpec { program: "/bin/sh".to_string(), args: vec!["-c".to_string(), "exit 0".to_string()], + shell: None, + runtime_helper: None, env: vec![], workdir: None, pty: false, @@ -700,6 +1024,8 @@ mod tests { .exec(ExecSpec { program: "/bin/sh".to_string(), args: vec!["-c".to_string(), "exit 7".to_string()], + shell: None, + runtime_helper: None, env: vec![], workdir: None, pty: true, diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index f4bb815f9..f126ec6d5 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -20,7 +20,7 @@ mod linux { use std::os::unix::fs::{FileTypeExt as _, MetadataExt as _, PermissionsExt as _}; use std::path::Path; use std::pin::Pin; - use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::task::{Context, Poll}; use std::time::Duration; @@ -177,8 +177,10 @@ mod linux { .map(|_| ControlConnectionSlot(active.clone())) } static BOUNDARY_TERMINATION_REQUESTED: AtomicBool = AtomicBool::new(false); + static BOUNDARY_TERMINATION_SIGNAL: AtomicI32 = AtomicI32::new(0); - extern "C" fn request_boundary_termination(_signal: libc::c_int) { + extern "C" fn request_boundary_termination(signal: libc::c_int) { + BOUNDARY_TERMINATION_SIGNAL.store(signal, Ordering::Release); BOUNDARY_TERMINATION_REQUESTED.store(true, Ordering::Release); } @@ -270,6 +272,7 @@ mod linux { fn install_boundary_signal_handlers() -> Result<(), String> { BOUNDARY_TERMINATION_REQUESTED.store(false, Ordering::Release); + BOUNDARY_TERMINATION_SIGNAL.store(0, Ordering::Release); let action = nix::sys::signal::SigAction::new( nix::sys::signal::SigHandler::Handler(request_boundary_termination), nix::sys::signal::SaFlags::empty(), @@ -483,7 +486,22 @@ mod linux { tracing::info!(?config, "Boundary control listener ready"); loop { if BOUNDARY_TERMINATION_REQUESTED.load(Ordering::Acquire) { + let signal = BOUNDARY_TERMINATION_SIGNAL.load(Ordering::Acquire); + let before_supervisor_confirmation = !matches!( + *lock(&runtime.supervisor_connection), + SupervisorConnectionState::Connected(_) + ); runtime.shutdown(); + if before_supervisor_confirmation { + return Err(format!( + "sandbox boundary received {} before supervisor confirmation", + boundary_termination_signal_name(signal) + )); + } + tracing::info!( + signal = boundary_termination_signal_name(signal), + "Sandbox boundary received termination signal" + ); return Ok(()); } match listener.accept() { @@ -521,6 +539,14 @@ mod linux { } } + fn boundary_termination_signal_name(signal: i32) -> &'static str { + match signal { + libc::SIGTERM => "SIGTERM", + libc::SIGINT => "SIGINT", + _ => "unknown signal", + } + } + async fn serve_control_connection( stream: ControlStream, runtime: Arc, @@ -3790,6 +3816,13 @@ mod linux { ); } + #[test] + fn boundary_termination_signal_name_is_explicit() { + assert_eq!(boundary_termination_signal_name(libc::SIGTERM), "SIGTERM"); + assert_eq!(boundary_termination_signal_name(libc::SIGINT), "SIGINT"); + assert_eq!(boundary_termination_signal_name(0), "unknown signal"); + } + #[test] fn supplementary_group_measurement_rejects_unexpected_groups_by_default() { assert!(!supplementary_groups_match(&[44, 992], &[], false)); @@ -4857,6 +4890,8 @@ mod linux { let exec_spec = ExecSpecWire { program: "/bin/sh".to_string(), args: vec!["-c".to_string(), "printf '%s' \"$REPLAY_TEST\"".to_string()], + shell: None, + runtime_helper: None, env: Vec::new(), workdir: None, pty: false, @@ -5137,6 +5172,8 @@ mod linux { let sleep_spec = ExecSpecWire { program: "/bin/sleep".to_string(), args: vec!["30".to_string()], + shell: None, + runtime_helper: None, env: Vec::new(), workdir: None, pty: false, @@ -5204,6 +5241,8 @@ mod linux { let spec = ExecSpecWire { program: "/bin/sh".to_string(), args: vec!["-c".to_string(), format!("exit {exit_code}")], + shell: None, + runtime_helper: None, env: Vec::new(), workdir: None, pty: false, @@ -5241,6 +5280,8 @@ mod linux { "if [ -z \"${ROTATED_TOKEN+x}\" ]; then printf revoked; else printf 'unexpected:%s' \"$ROTATED_TOKEN\"; fi" .to_string(), ], + shell: None, + runtime_helper: None, env: Vec::new(), workdir: None, pty: false, diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 4166d8d2c..957b55c66 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -24,6 +24,8 @@ pub mod perf; pub mod process; mod pty; pub mod sandbox; +#[cfg(target_os = "linux")] +pub mod sftp; /// Results of actively qualifying the admitted workload runtime before the /// sandbox consumes protected bootstrap material. diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index dbdc8733e..a2c68fbcf 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -1518,13 +1518,37 @@ fn launch_capability_probe(_args: &[String]) -> Result<()> { fn launch_capability_free(args: &[String]) -> Result<()> { use miette::{Context as _, IntoDiagnostic as _}; - let [uid, gid, bootstrap] = args else { + let [uid, gid, bootstrap, workspace @ ..] = args else { return Err(miette::miette!( - "usage: openshell-sandbox {CAPABILITY_FREE_LAUNCH_SUBCOMMAND} " + "usage: openshell-sandbox {CAPABILITY_FREE_LAUNCH_SUBCOMMAND} [WORKSPACE]" )); }; + if workspace.len() > 1 { + return Err(miette::miette!( + "usage: openshell-sandbox {CAPABILITY_FREE_LAUNCH_SUBCOMMAND} [WORKSPACE]" + )); + } let uid = uid.parse::().into_diagnostic().wrap_err("parse UID")?; let gid = gid.parse::().into_diagnostic().wrap_err("parse GID")?; + if let Some(workspace) = workspace.first() { + let workspace = Path::new(workspace); + let metadata = std::fs::symlink_metadata(workspace) + .into_diagnostic() + .wrap_err_with(|| format!("read workspace metadata {}", workspace.display()))?; + if !metadata.file_type().is_dir() { + return Err(miette::miette!( + "workspace {} must be a real directory", + workspace.display() + )); + } + nix::unistd::chown( + workspace, + Some(nix::unistd::Uid::from_raw(uid)), + Some(nix::unistd::Gid::from_raw(gid)), + ) + .into_diagnostic() + .wrap_err_with(|| format!("set workspace ownership {}:{gid}", workspace.display()))?; + } enter_capability_free_identity(uid, gid)?; let log_level = std::env::var(openshell_core::sandbox_env::LOG_LEVEL) .unwrap_or_else(|_| "warn".to_string()); diff --git a/crates/openshell-sandbox/src/process.rs b/crates/openshell-sandbox/src/process.rs index 56d4f244a..7b61d3003 100644 --- a/crates/openshell-sandbox/src/process.rs +++ b/crates/openshell-sandbox/src/process.rs @@ -249,8 +249,8 @@ fn apply_canonical_process_environment( ) { cmd.envs(user_environment); let (session_user, session_home) = session_user_and_home(policy, workspace.home()); - // Resolve a shell present in the sandbox image (minimal images such as - // Alpine ship only `/bin/sh`, not bash). Runs in the supervisor. + // Resolve a shell present in the workload image. This code runs inside the + // workload boundary, where the image filesystem is visible. let shell = openshell_core::shell::detect_login_shell(); for (key, value) in [ diff --git a/crates/openshell-sandbox/src/sftp.rs b/crates/openshell-sandbox/src/sftp.rs new file mode 100644 index 000000000..2cf02a839 --- /dev/null +++ b/crates/openshell-sandbox/src/sftp.rs @@ -0,0 +1,720 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! SFTP v3 adapter backed by a directory file descriptor. + +use std::collections::HashMap; +use std::ffi::{OsStr, OsString}; +use std::fs::File as StdFile; +use std::io; +use std::os::fd::{AsFd, OwnedFd}; +use std::os::unix::ffi::OsStringExt as _; +use std::path::{Component, Path, PathBuf}; + +use miette::{IntoDiagnostic as _, Result}; +use russh_sftp::protocol::{ + Attrs, Data, File, FileAttributes, Handle, Name, OpenFlags, Status, StatusCode, +}; +use rustix::fs::{ + AtFlags, Dir, FileType, Gid, Mode, OFlags, RenameFlags, ResolveFlags, Timestamps, Uid, fchmod, + fchown, fstat, futimens, mkdirat, openat, openat2, readlinkat, renameat_with, statat, + symlinkat, unlinkat, +}; +use tokio::io::{AsyncReadExt as _, AsyncSeekExt as _, AsyncWriteExt as _}; + +const MAX_HANDLES: usize = 256; +const MAX_PACKET_SIZE: u32 = 256 * 1024; +const MAX_READ_SIZE: usize = MAX_PACKET_SIZE as usize; +const MAX_DIRECTORY_ENTRIES: usize = 128; +const SAFE_MODE_MASK: u32 = 0o777; + +struct SftpHandler { + root: OwnedFd, + root_path: PathBuf, + files: HashMap, + directories: HashMap, + done: Option>, +} + +impl Drop for SftpHandler { + fn drop(&mut self) { + if let Some(done) = self.done.take() { + let _ = done.send(()); + } + } +} + +impl SftpHandler { + fn new(root: &Path, done: tokio::sync::oneshot::Sender<()>) -> io::Result { + let root_path = std::fs::canonicalize(root)?; + let root = openat( + rustix::fs::CWD, + &root_path, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW, + Mode::empty(), + ) + .map_err(io::Error::from)?; + Ok(Self { + root, + root_path, + files: HashMap::new(), + directories: HashMap::new(), + done: Some(done), + }) + } + + fn path(&self, path: &str) -> Result { + let supplied = Path::new(path); + let supplied = if supplied.is_absolute() && supplied.starts_with(&self.root_path) { + supplied + .strip_prefix(&self.root_path) + .map_err(|_| StatusCode::PermissionDenied)? + } else { + supplied + }; + let mut relative = PathBuf::new(); + for component in supplied.components() { + match component { + Component::RootDir | Component::CurDir => {} + Component::Normal(value) => relative.push(value), + Component::ParentDir | Component::Prefix(_) => { + return Err(StatusCode::PermissionDenied); + } + } + } + if relative.as_os_str().is_empty() { + relative.push("."); + } + Ok(relative) + } + + fn parent(&self, path: &str) -> Result<(OwnedFd, OsString), StatusCode> { + let path = self.path(path)?; + if path == Path::new(".") { + return Err(StatusCode::PermissionDenied); + } + let leaf = path + .file_name() + .ok_or(StatusCode::PermissionDenied)? + .to_os_string(); + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let fd = self.open_path( + parent, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + )?; + Ok((fd, leaf)) + } + + fn open_path(&self, path: &Path, flags: OFlags, mode: Mode) -> Result { + openat2( + &self.root, + path, + flags, + mode, + ResolveFlags::IN_ROOT | ResolveFlags::NO_MAGICLINKS, + ) + .map_err(status_code) + } + + fn reserve_handle(&self) -> Result<(), StatusCode> { + if self.files.len() + self.directories.len() >= MAX_HANDLES { + Err(StatusCode::Failure) + } else { + Ok(()) + } + } + + fn handle() -> String { + uuid::Uuid::new_v4().simple().to_string() + } +} + +fn status_code(error: rustix::io::Errno) -> StatusCode { + match error { + rustix::io::Errno::NOENT | rustix::io::Errno::NOTDIR => StatusCode::NoSuchFile, + rustix::io::Errno::ACCESS + | rustix::io::Errno::PERM + | rustix::io::Errno::LOOP + | rustix::io::Errno::XDEV => StatusCode::PermissionDenied, + _ => StatusCode::Failure, + } +} + +fn io_status(error: io::Error) -> StatusCode { + error + .raw_os_error() + .map(rustix::io::Errno::from_raw_os_error) + .map_or(StatusCode::Failure, status_code) +} + +fn ok(id: u32) -> Status { + Status { + id, + status_code: StatusCode::Ok, + error_message: String::new(), + language_tag: "en-US".to_string(), + } +} + +fn attributes(stat: &rustix::fs::Stat) -> FileAttributes { + FileAttributes { + size: stat.st_size.try_into().ok(), + uid: Some(stat.st_uid), + user: None, + gid: Some(stat.st_gid), + group: None, + permissions: Some(stat.st_mode), + atime: stat.st_atime.try_into().ok(), + mtime: stat.st_mtime.try_into().ok(), + } +} + +fn open_flags(flags: OpenFlags) -> OFlags { + let mut result = OFlags::CLOEXEC | OFlags::NONBLOCK; + result |= if flags.contains(OpenFlags::READ) && flags.contains(OpenFlags::WRITE) { + OFlags::RDWR + } else if flags.contains(OpenFlags::WRITE) { + OFlags::WRONLY + } else { + OFlags::RDONLY + }; + if flags.contains(OpenFlags::APPEND) { + result |= OFlags::APPEND; + } + if flags.contains(OpenFlags::CREATE) { + result |= OFlags::CREATE; + } + if flags.contains(OpenFlags::TRUNCATE) { + result |= OFlags::TRUNC; + } + if flags.contains(OpenFlags::EXCLUDE) { + result |= OFlags::EXCL; + } + result +} + +fn ensure_regular_file(fd: impl AsFd) -> Result<(), StatusCode> { + let stat = fstat(fd).map_err(status_code)?; + if FileType::from_raw_mode(stat.st_mode).is_file() { + Ok(()) + } else { + Err(StatusCode::OpUnsupported) + } +} + +fn ensure_setstat_target(fd: impl AsFd, changes_size: bool) -> Result<(), StatusCode> { + let stat = fstat(fd).map_err(status_code)?; + let file_type = FileType::from_raw_mode(stat.st_mode); + if file_type.is_file() || (!changes_size && file_type.is_dir()) { + Ok(()) + } else { + Err(StatusCode::OpUnsupported) + } +} + +fn set_attributes(fd: impl AsFd, attrs: &FileAttributes) -> Result<(), StatusCode> { + if attrs.uid.is_some() || attrs.gid.is_some() { + fchown( + &fd, + attrs.uid.map(Uid::from_raw), + attrs.gid.map(Gid::from_raw), + ) + .map_err(status_code)?; + } + if let Some(mode) = attrs.permissions { + fchmod(&fd, Mode::from_bits_truncate(mode & SAFE_MODE_MASK)).map_err(status_code)?; + } + if attrs.atime.is_some() || attrs.mtime.is_some() { + let current = fstat(&fd).map_err(status_code)?; + let times = Timestamps { + last_access: rustix::fs::Timespec { + tv_sec: attrs.atime.map_or(current.st_atime, i64::from), + tv_nsec: 0, + }, + last_modification: rustix::fs::Timespec { + tv_sec: attrs.mtime.map_or(current.st_mtime, i64::from), + tv_nsec: 0, + }, + }; + futimens(fd, ×).map_err(status_code)?; + } + Ok(()) +} + +impl russh_sftp::server::Handler for SftpHandler { + type Error = StatusCode; + + fn unimplemented(&self) -> Self::Error { + StatusCode::OpUnsupported + } + + async fn open( + &mut self, + id: u32, + filename: String, + pflags: OpenFlags, + attrs: FileAttributes, + ) -> Result { + self.reserve_handle()?; + if pflags.contains(OpenFlags::EXCLUDE) && !pflags.contains(OpenFlags::CREATE) { + return Err(StatusCode::BadMessage); + } + let path = self.path(&filename)?; + let flags = open_flags(pflags); + let mode = if flags.contains(OFlags::CREATE) { + Mode::from_bits_truncate(attrs.permissions.unwrap_or(0o666) & SAFE_MODE_MASK) + } else { + Mode::empty() + }; + let fd = self.open_path(&path, flags, mode)?; + ensure_regular_file(&fd)?; + let handle = Self::handle(); + self.files + .insert(handle.clone(), tokio::fs::File::from_std(StdFile::from(fd))); + Ok(Handle { id, handle }) + } + + async fn close(&mut self, id: u32, handle: String) -> Result { + if self.files.remove(&handle).is_none() && self.directories.remove(&handle).is_none() { + return Err(StatusCode::Failure); + } + Ok(ok(id)) + } + + async fn read( + &mut self, + id: u32, + handle: String, + offset: u64, + len: u32, + ) -> Result { + let file = self.files.get_mut(&handle).ok_or(StatusCode::Failure)?; + file.seek(io::SeekFrom::Start(offset)) + .await + .map_err(io_status)?; + let requested = usize::try_from(len) + .map_err(|_| StatusCode::BadMessage)? + .min(MAX_READ_SIZE); + let mut data = vec![0; requested]; + let count = file.read(&mut data).await.map_err(io_status)?; + if count == 0 { + return Err(StatusCode::Eof); + } + data.truncate(count); + Ok(Data { id, data }) + } + + async fn write( + &mut self, + id: u32, + handle: String, + offset: u64, + data: Vec, + ) -> Result { + let file = self.files.get_mut(&handle).ok_or(StatusCode::Failure)?; + file.seek(io::SeekFrom::Start(offset)) + .await + .map_err(io_status)?; + file.write_all(&data).await.map_err(io_status)?; + Ok(ok(id)) + } + + async fn lstat(&mut self, id: u32, path: String) -> Result { + if self.path(&path)? == Path::new(".") { + let stat = fstat(&self.root).map_err(status_code)?; + return Ok(Attrs { + id, + attrs: attributes(&stat), + }); + } + let (parent, leaf) = self.parent(&path)?; + let stat = statat(parent, leaf, AtFlags::SYMLINK_NOFOLLOW).map_err(status_code)?; + Ok(Attrs { + id, + attrs: attributes(&stat), + }) + } + + async fn stat(&mut self, id: u32, path: String) -> Result { + let path = self.path(&path)?; + let fd = self.open_path(&path, OFlags::PATH | OFlags::CLOEXEC, Mode::empty())?; + let stat = fstat(fd).map_err(status_code)?; + Ok(Attrs { + id, + attrs: attributes(&stat), + }) + } + + async fn fstat(&mut self, id: u32, handle: String) -> Result { + let file = self.files.get(&handle).ok_or(StatusCode::Failure)?; + let stat = fstat(file).map_err(status_code)?; + Ok(Attrs { + id, + attrs: attributes(&stat), + }) + } + + async fn fsetstat( + &mut self, + id: u32, + handle: String, + attrs: FileAttributes, + ) -> Result { + let file = self.files.get_mut(&handle).ok_or(StatusCode::Failure)?; + if let Some(size) = attrs.size { + file.set_len(size).await.map_err(io_status)?; + } + set_attributes(&*file, &attrs)?; + Ok(ok(id)) + } + + async fn opendir(&mut self, id: u32, path: String) -> Result { + self.reserve_handle()?; + let path = self.path(&path)?; + let fd = self.open_path( + &path, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + )?; + let directory = Dir::new(fd).map_err(status_code)?; + let handle = Self::handle(); + self.directories.insert(handle.clone(), directory); + Ok(Handle { id, handle }) + } + + async fn readdir(&mut self, id: u32, handle: String) -> Result { + let directory = self + .directories + .get_mut(&handle) + .ok_or(StatusCode::Failure)?; + let mut files = Vec::with_capacity(MAX_DIRECTORY_ENTRIES); + while files.len() < MAX_DIRECTORY_ENTRIES { + let Some(entry) = directory.next() else { + break; + }; + let entry = entry.map_err(status_code)?; + let name = entry.file_name(); + if name.to_bytes() == b"." || name.to_bytes() == b".." { + continue; + } + let stat = statat( + directory.fd().map_err(status_code)?, + name, + AtFlags::SYMLINK_NOFOLLOW, + ) + .map_err(status_code)?; + files.push(File::new(name.to_string_lossy(), attributes(&stat))); + } + if files.is_empty() { + Err(StatusCode::Eof) + } else { + Ok(Name { id, files }) + } + } + + async fn remove(&mut self, id: u32, filename: String) -> Result { + let (parent, leaf) = self.parent(&filename)?; + unlinkat(parent, leaf, AtFlags::empty()).map_err(status_code)?; + Ok(ok(id)) + } + + async fn mkdir( + &mut self, + id: u32, + path: String, + attrs: FileAttributes, + ) -> Result { + let (parent, leaf) = self.parent(&path)?; + let mode = Mode::from_bits_truncate(attrs.permissions.unwrap_or(0o777) & SAFE_MODE_MASK); + mkdirat(parent, leaf, mode).map_err(status_code)?; + Ok(ok(id)) + } + + async fn rmdir(&mut self, id: u32, path: String) -> Result { + let (parent, leaf) = self.parent(&path)?; + unlinkat(parent, leaf, AtFlags::REMOVEDIR).map_err(status_code)?; + Ok(ok(id)) + } + + async fn realpath(&mut self, id: u32, path: String) -> Result { + let path = self.path(&path)?; + if let Err(error) = self.open_path(&path, OFlags::PATH | OFlags::CLOEXEC, Mode::empty()) { + if error != StatusCode::NoSuchFile { + return Err(error); + } + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + self.open_path( + parent, + OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + )?; + } + let display = if path == Path::new(".") { + "/".to_string() + } else { + format!("/{}", path.display()) + }; + Ok(Name { + id, + files: vec![File::dummy(display)], + }) + } + + async fn rename( + &mut self, + id: u32, + oldpath: String, + newpath: String, + ) -> Result { + let (old_parent, old_leaf) = self.parent(&oldpath)?; + let (new_parent, new_leaf) = self.parent(&newpath)?; + renameat_with( + old_parent, + old_leaf, + new_parent, + new_leaf, + RenameFlags::NOREPLACE, + ) + .map_err(status_code)?; + Ok(ok(id)) + } + + async fn readlink(&mut self, id: u32, path: String) -> Result { + let (parent, leaf) = self.parent(&path)?; + let target = readlinkat(parent, leaf, Vec::new()).map_err(status_code)?; + let target = OsString::from_vec(target.into_bytes()); + Ok(Name { + id, + files: vec![File::dummy(target.to_string_lossy())], + }) + } + + async fn symlink( + &mut self, + id: u32, + linkpath: String, + targetpath: String, + ) -> Result { + let (parent, leaf) = self.parent(&linkpath)?; + symlinkat(OsStr::new(&targetpath), parent, leaf).map_err(status_code)?; + Ok(ok(id)) + } + + async fn setstat( + &mut self, + id: u32, + path: String, + attrs: FileAttributes, + ) -> Result { + let path = self.path(&path)?; + let flags = if attrs.size.is_some() { + OFlags::WRONLY | OFlags::CLOEXEC | OFlags::NONBLOCK + } else { + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NONBLOCK + }; + let fd = self.open_path(&path, flags, Mode::empty())?; + ensure_setstat_target(&fd, attrs.size.is_some())?; + if let Some(size) = attrs.size { + StdFile::from(fd.try_clone().map_err(io_status)?) + .set_len(size) + .map_err(io_status)?; + } + set_attributes(&fd, &attrs)?; + Ok(ok(id)) + } +} + +pub(crate) async fn serve(stream: S, root: PathBuf) -> Result<()> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static, +{ + let (done_tx, done_rx) = tokio::sync::oneshot::channel(); + let handler = SftpHandler::new(&root, done_tx).into_diagnostic()?; + russh_sftp::server::run_with_config( + stream, + handler, + russh_sftp::server::Config { + max_client_packet_len: MAX_PACKET_SIZE, + }, + ) + .await; + let _ = done_rx.await; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use russh_sftp::client::SftpSession; + use std::os::unix::fs::PermissionsExt as _; + use std::time::Duration; + + async fn client(root: &Path) -> SftpSession { + let (done_tx, _done_rx) = tokio::sync::oneshot::channel(); + let handler = SftpHandler::new(root, done_tx).unwrap(); + let (client_stream, server_stream) = tokio::io::duplex(1024 * 1024); + russh_sftp::server::run_with_config( + server_stream, + handler, + russh_sftp::server::Config { + max_client_packet_len: MAX_PACKET_SIZE, + }, + ) + .await; + SftpSession::new(client_stream).await.unwrap() + } + + #[tokio::test] + async fn adapter_round_trips_files() { + let root = tempfile::tempdir().unwrap(); + let client = client(root.path()).await; + let mut file = client + .open_with_flags( + "hello.txt", + OpenFlags::CREATE | OpenFlags::TRUNCATE | OpenFlags::WRITE | OpenFlags::READ, + ) + .await + .unwrap(); + file.write_all(b"hello from sftp").await.unwrap(); + file.rewind().await.unwrap(); + let mut contents = String::new(); + file.read_to_string(&mut contents).await.unwrap(); + assert_eq!(contents, "hello from sftp"); + drop(file); + + let mut file = client.open("hello.txt").await.unwrap(); + let mut contents = String::new(); + file.read_to_string(&mut contents).await.unwrap(); + assert_eq!(contents, "hello from sftp"); + } + + #[tokio::test] + async fn adapter_creates_entries_at_virtual_root() { + let root = tempfile::tempdir().unwrap(); + let client = client(root.path()).await; + + client.create_dir("incoming").await.unwrap(); + + assert!(root.path().join("incoming").is_dir()); + } + + #[tokio::test] + async fn realpath_accepts_a_missing_leaf_under_an_existing_directory() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("incoming")).unwrap(); + let client = client(root.path()).await; + + let canonical = client.canonicalize("incoming/tree").await.unwrap(); + + assert_eq!(canonical, PathBuf::from("/incoming/tree")); + } + + #[tokio::test] + async fn adapter_rejects_parent_and_symlink_escapes() { + let parent = tempfile::tempdir().unwrap(); + let root = parent.path().join("root"); + std::fs::create_dir(&root).unwrap(); + std::fs::write(parent.path().join("outside"), b"secret").unwrap(); + std::os::unix::fs::symlink("../outside", root.join("escape")).unwrap(); + let client = client(&root).await; + + assert!(client.metadata("../outside").await.is_err()); + assert!(client.metadata("escape").await.is_err()); + assert!(client.open("escape").await.is_err()); + } + + #[tokio::test] + async fn standard_rename_preserves_an_existing_destination() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("source"), b"source contents").unwrap(); + std::fs::write(root.path().join("destination"), b"destination contents").unwrap(); + let client = client(root.path()).await; + + assert!(client.rename("source", "destination").await.is_err()); + assert_eq!( + std::fs::read(root.path().join("source")).unwrap(), + b"source contents" + ); + assert_eq!( + std::fs::read(root.path().join("destination")).unwrap(), + b"destination contents" + ); + + client.rename("source", "renamed").await.unwrap(); + assert_eq!( + std::fs::read(root.path().join("renamed")).unwrap(), + b"source contents" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn fifo_open_fails_without_blocking() { + let root = tempfile::tempdir().unwrap(); + let fifo = root.path().join("fifo"); + rustix::fs::mkfifoat(rustix::fs::CWD, &fifo, Mode::from_bits_truncate(0o600)).unwrap(); + let client = client(root.path()).await; + + let result = tokio::time::timeout(Duration::from_secs(1), client.open("fifo")).await; + if result.is_err() { + let _ = openat( + rustix::fs::CWD, + &fifo, + OFlags::WRONLY | OFlags::NONBLOCK | OFlags::CLOEXEC, + Mode::empty(), + ); + } + + assert!(matches!(result, Ok(Err(_)))); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn fifo_setstat_fails_without_blocking() { + let root = tempfile::tempdir().unwrap(); + let fifo = root.path().join("fifo"); + rustix::fs::mkfifoat(rustix::fs::CWD, &fifo, Mode::from_bits_truncate(0o600)).unwrap(); + let client = client(root.path()).await; + + let result = tokio::time::timeout( + Duration::from_secs(1), + client.set_metadata("fifo", FileAttributes::default()), + ) + .await; + if result.is_err() { + let _ = openat( + rustix::fs::CWD, + &fifo, + OFlags::WRONLY | OFlags::NONBLOCK | OFlags::CLOEXEC, + Mode::empty(), + ); + } + + assert!(matches!(result, Ok(Err(_)))); + } + + #[tokio::test] + async fn setstat_preserves_directory_metadata_support() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("directory")).unwrap(); + let client = client(root.path()).await; + let attrs = FileAttributes { + permissions: Some(0o700), + ..FileAttributes::default() + }; + + client.set_metadata("directory", attrs).await.unwrap(); + + let mode = std::fs::metadata(root.path().join("directory")) + .unwrap() + .permissions() + .mode() + & SAFE_MODE_MASK; + assert_eq!(mode, 0o700); + } +} diff --git a/crates/openshell-sdk/README.md b/crates/openshell-sdk/README.md index 423d04488..47805e38e 100644 --- a/crates/openshell-sdk/README.md +++ b/crates/openshell-sdk/README.md @@ -113,7 +113,7 @@ client }), spec: Some(SandboxWorkloadTemplateSpec { workload: Some(SandboxWorkloadConfig { - image: "ghcr.io/nvidia/openshell-community/sandboxes/python:latest".to_string(), + image: "registry.example.com/agents/python:latest".to_string(), ..Default::default() }), ..Default::default() diff --git a/crates/openshell-sdk/src/types.rs b/crates/openshell-sdk/src/types.rs index d09eaabf1..a9fd544f1 100644 --- a/crates/openshell-sdk/src/types.rs +++ b/crates/openshell-sdk/src/types.rs @@ -146,7 +146,7 @@ impl From for SandboxPhase { pub struct SandboxSpec { /// Optional user-supplied sandbox name. When empty the server generates one. pub name: Option, - /// Container image reference (e.g. `ghcr.io/nvidia/openshell-community/sandboxes/python:latest`). + /// Container image reference (e.g. `registry.example.com/agents/python:latest`). pub image: Option, /// Labels attached to the sandbox. pub labels: HashMap, diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index d547f64b7..4afe398af 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -13592,7 +13592,7 @@ mod tests { ..Default::default() }), template: Some(SandboxTemplate { - image: "ghcr.io/nvidia/openshell-community/sandboxes/base:latest".to_string(), + image: "nvcr.io/nvidia/base/ubuntu:24.04".to_string(), driver_config: Some(prost_types::Struct { fields: [ ( diff --git a/crates/openshell-server/src/grpc/interactive_exec_tests.rs b/crates/openshell-server/src/grpc/interactive_exec_tests.rs new file mode 100644 index 000000000..63be73ff5 --- /dev/null +++ b/crates/openshell-server/src/grpc/interactive_exec_tests.rs @@ -0,0 +1,537 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Exercise the gateway's actual SSH input/output bridge with a controllable peer. + +use super::*; +use openshell_core::proto::{exec_sandbox_event, exec_sandbox_input}; +use russh::server::{Auth, ChannelOpenHandle, Handler, Msg, Session}; +use std::time::Duration; + +struct ExecPeer { + channel: Option>, + echo_tx: Option>>, + input: Arc>>, + eof: Arc, + release_output: Arc, + output_task: Option>, +} + +impl Drop for ExecPeer { + fn drop(&mut self) { + if let Some(task) = self.output_task.take() { + task.abort(); + } + } +} + +impl Handler for ExecPeer { + type Error = russh::Error; + + async fn auth_none(&mut self, _user: &str) -> Result { + Ok(Auth::Accept) + } + + async fn channel_open_session( + &mut self, + channel: russh::Channel, + reply: ChannelOpenHandle, + _session: &mut Session, + ) -> Result<(), Self::Error> { + reply.accept().await; + self.channel = Some(channel); + Ok(()) + } + + async fn exec_request( + &mut self, + channel: russh::ChannelId, + data: &[u8], + session: &mut Session, + ) -> Result<(), Self::Error> { + session.channel_success(channel)?; + if data == b"duplex" { + let channel = self.channel.take().unwrap(); + let release = self.release_output.clone(); + let (echo_tx, mut echo_rx) = mpsc::unbounded_channel::>(); + self.echo_tx = Some(echo_tx); + self.output_task = Some(tokio::spawn(async move { + let (reader, writer) = channel.split(); + // The handler receives stdin independently of output flow + // control, as a real process with separate I/O pumps would. + drop(reader); + while let Some(data) = echo_rx.recv().await { + writer.data_bytes(data.clone()).await.unwrap(); + writer.extended_data_bytes(1, data).await.unwrap(); + } + release.notified().await; + writer.exit_status(7).await.unwrap(); + writer.close().await.unwrap(); + })); + } else { + self.channel.take(); + } + if data == b"early" { + session.exit_status_request(channel, 0)?; + session.close(channel)?; + return Ok(()); + } + // Signal that SSH setup is complete without depending on input EOF. + session.data(channel, b"ready".to_vec())?; + Ok(()) + } + + async fn data( + &mut self, + _channel: russh::ChannelId, + data: &[u8], + _session: &mut Session, + ) -> Result<(), Self::Error> { + self.input.lock().unwrap().extend_from_slice(data); + if let Some(tx) = &self.echo_tx { + tx.send(data.to_vec()).unwrap(); + } + Ok(()) + } + + async fn channel_eof( + &mut self, + channel: russh::ChannelId, + session: &mut Session, + ) -> Result<(), Self::Error> { + self.eof.store(true, Ordering::SeqCst); + self.echo_tx.take(); + if self.output_task.is_some() { + return Ok(()); + } + let release = self.release_output.clone(); + let handle = session.handle(); + self.output_task = Some(tokio::spawn(async move { + release.notified().await; + if handle.data(channel, b"after eof".to_vec()).await.is_err() { + return; + } + let _ = handle + .extended_data(channel, 1, b"stderr after eof".to_vec()) + .await; + let _ = handle.exit_status_request(channel, 7).await; + let _ = handle.eof(channel).await; + let _ = handle.close(channel).await; + })); + Ok(()) + } + + async fn channel_close( + &mut self, + _channel: russh::ChannelId, + _session: &mut Session, + ) -> Result<(), Self::Error> { + // A client CLOSE before release must really prevent later output. + if let Some(task) = self.output_task.take() { + task.abort(); + } + Ok(()) + } +} + +struct Fixture { + port: u16, + input: Arc>>, + eof: Arc, + release_output: Arc, + server: tokio::task::JoinHandle<()>, +} + +impl Drop for Fixture { + fn drop(&mut self) { + self.server.abort(); + } +} + +impl Fixture { + async fn new() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let mut config = russh::server::Config::default(); + config + .keys + .push(russh::keys::ssh_key::private::Ed25519Keypair::from_seed(&rand::random()).into()); + let input = Arc::new(std::sync::Mutex::new(Vec::new())); + let eof = Arc::new(AtomicBool::new(false)); + let release_output = Arc::new(tokio::sync::Notify::new()); + let handler = ExecPeer { + channel: None, + echo_tx: None, + input: input.clone(), + eof: eof.clone(), + release_output: release_output.clone(), + output_task: None, + }; + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + set_tcp_nodelay_best_effort(&socket); + if let Ok(session) = russh::server::run_stream(Arc::new(config), socket, handler).await + { + let _ = session.await; + } + }); + Self { + port, + input, + eof, + release_output, + server, + } + } +} + +async fn ready(rx: &mut mpsc::Receiver>) { + let event = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!( + matches!(event.payload, Some(exec_sandbox_event::Payload::Stdout(s)) if s.data == b"ready") + ); +} + +#[tokio::test] +async fn interactive_exec_drains_stdout_and_stderr_after_input_eof() { + let fixture = Fixture::new().await; + let (input_tx, input_rx) = mpsc::channel(2); + let (output_tx, mut output_rx) = mpsc::channel(2); + let exec = run_interactive_exec_with_russh( + fixture.port, + "test", + ReceiverStream::new(input_rx), + false, + false, + 0, + 0, + output_tx, + ); + let exercise = async { + ready(&mut output_rx).await; + input_tx + .send(Ok(ExecSandboxInput { + payload: Some(exec_sandbox_input::Payload::Stdin(b"input".to_vec())), + })) + .await + .unwrap(); + drop(input_tx); + while !fixture.eof.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + // Keep the peer silent briefly after EOF so a premature SSH CLOSE is + // processed before output is released. + tokio::time::sleep(Duration::from_millis(50)).await; + fixture.release_output.notify_one(); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + while let Some(event) = output_rx.recv().await { + match event.unwrap().payload.unwrap() { + exec_sandbox_event::Payload::Stdout(s) => stdout.extend(s.data), + exec_sandbox_event::Payload::Stderr(s) => stderr.extend(s.data), + other @ exec_sandbox_event::Payload::Exit(_) => { + panic!("unexpected event: {other:?}") + } + } + } + assert_eq!(stdout, b"after eof"); + assert_eq!(stderr, b"stderr after eof"); + assert_eq!(*fixture.input.lock().unwrap(), b"input"); + }; + let (result, ()) = tokio::time::timeout(Duration::from_secs(5), async { + tokio::join!(exec, exercise) + }) + .await + .unwrap(); + assert_eq!(result.unwrap(), 7); +} + +#[tokio::test] +async fn interactive_exec_makes_progress_in_both_directions_before_eof() { + const CHUNKS: usize = 128; + const CHUNK_SIZE: usize = 64 * 1024; + const BATCH: usize = 4; + let fixture = Fixture::new().await; + let (input_tx, input_rx) = mpsc::channel(16); + let (output_tx, mut output_rx) = mpsc::channel(2); + let drained = tokio::sync::Notify::new(); + let progress = std::cell::Cell::new((0, 0)); + let exec = run_interactive_exec_with_russh( + fixture.port, + "duplex", + ReceiverStream::new(input_rx), + false, + false, + 0, + 0, + output_tx, + ); + let writer = async { + for chunk in 1..=CHUNKS { + input_tx + .send(Ok(ExecSandboxInput { + payload: Some(exec_sandbox_input::Payload::Stdin(vec![b'x'; CHUNK_SIZE])), + })) + .await + .unwrap(); + if chunk % BATCH == 0 { + drained.notified().await; + } + } + // Keep the request stream open until BOTH output streams have drained. + // Bound in-flight data to exercise sustained interactive traffic without + // saturating both ends of the fixture's SSH transport simultaneously. + drop(input_tx); + }; + let reader = async { + ready(&mut output_rx).await; + let mut stdout = 0; + let mut stderr = 0; + let mut acknowledged = 0; + while stdout < CHUNKS * CHUNK_SIZE || stderr < CHUNKS * CHUNK_SIZE { + let bytes = match output_rx.recv().await.unwrap().unwrap().payload.unwrap() { + exec_sandbox_event::Payload::Stdout(s) => { + stdout += s.data.len(); + s.data + } + exec_sandbox_event::Payload::Stderr(s) => { + stderr += s.data.len(); + s.data + } + event @ exec_sandbox_event::Payload::Exit(_) => { + panic!("unexpected event: {event:?}") + } + }; + assert!(bytes.iter().all(|b| *b == b'x')); + progress.set((stdout, stderr)); + assert!(!fixture.eof.load(Ordering::SeqCst)); + if stdout.min(stderr) >= acknowledged + BATCH * CHUNK_SIZE { + acknowledged += BATCH * CHUNK_SIZE; + drained.notify_one(); + } + } + assert_eq!(stdout, CHUNKS * CHUNK_SIZE); + assert_eq!(stderr, CHUNKS * CHUNK_SIZE); + fixture.release_output.notify_one(); + while output_rx.recv().await.is_some() {} + }; + let (result, (), ()) = tokio::time::timeout(Duration::from_secs(30), async { + tokio::join!(exec, writer, reader) + }) + .await + .unwrap_or_else(|_| { + panic!( + "duplex stalled: input={}, output={:?}, eof={}", + fixture.input.lock().unwrap().len(), + progress.get(), + fixture.eof.load(Ordering::SeqCst) + ) + }); + assert_eq!(result.unwrap(), 7); + assert_eq!(fixture.input.lock().unwrap().len(), CHUNKS * CHUNK_SIZE); +} + +#[tokio::test] +async fn interactive_exec_ready_resize_stream_does_not_starve_output() { + use futures::StreamExt; + use std::sync::atomic::AtomicUsize; + + // Finite to make a regression fail rather than wedge the runtime forever. + // These frames have no SSH write await because this session has no PTY. + const FRAMES: usize = 100_000; + let fixture = Fixture::new().await; + let consumed = AtomicUsize::new(0); + let input = futures::stream::repeat_with(|| { + consumed.fetch_add(1, Ordering::SeqCst); + Ok(ExecSandboxInput { + payload: Some(exec_sandbox_input::Payload::Resize( + openshell_core::proto::ExecSandboxWindowResize::default(), + )), + }) + }) + .take(FRAMES); + let (output_tx, mut output_rx) = mpsc::channel(2); + let exec = + run_interactive_exec_with_russh(fixture.port, "test", input, false, false, 0, 0, output_tx); + let reader = async { + ready(&mut output_rx).await; + assert!( + consumed.load(Ordering::SeqCst) < FRAMES, + "output must be delivered before the continuously ready input ends" + ); + drop(output_rx); + }; + let (result, ()) = + tokio::time::timeout(Duration::from_secs(5), async { tokio::join!(exec, reader) }) + .await + .unwrap(); + assert_eq!(result.unwrap_err().code(), tonic::Code::Cancelled); +} + +#[tokio::test] +async fn interactive_exec_input_error_is_not_graceful_eof() { + for message in [ + Err(Status::cancelled("input cancelled")), + Ok(ExecSandboxInput { payload: None }), + Ok(ExecSandboxInput { + payload: Some(exec_sandbox_input::Payload::Start( + ExecSandboxRequest::default(), + )), + }), + ] { + let expected = message + .as_ref() + .err() + .map_or(tonic::Code::InvalidArgument, Status::code); + let fixture = Fixture::new().await; + let (input_tx, input_rx) = mpsc::channel(1); + let (output_tx, mut output_rx) = mpsc::channel(1); + let exec = run_interactive_exec_with_russh( + fixture.port, + "test", + ReceiverStream::new(input_rx), + false, + false, + 0, + 0, + output_tx, + ); + let exercise = async { + ready(&mut output_rx).await; + input_tx.send(message).await.unwrap(); + while output_rx.recv().await.is_some() {} + }; + let (result, ()) = tokio::time::timeout(Duration::from_secs(5), async { + tokio::join!(exec, exercise) + }) + .await + .unwrap(); + assert_eq!(result.unwrap_err().code(), expected); + assert!(!fixture.eof.load(Ordering::SeqCst)); + } +} + +#[tokio::test] +async fn interactive_exec_response_drop_cancels_idle_input() { + let fixture = Fixture::new().await; + let (input_tx, input_rx) = mpsc::channel(1); + let (output_tx, mut output_rx) = mpsc::channel(1); + let exec = run_interactive_exec_with_russh( + fixture.port, + "test", + ReceiverStream::new(input_rx), + false, + false, + 0, + 0, + output_tx, + ); + let exercise = async { + ready(&mut output_rx).await; + drop(output_rx); + }; + let (result, ()) = tokio::time::timeout(Duration::from_secs(5), async { + tokio::join!(exec, exercise) + }) + .await + .unwrap(); + assert_eq!(result.unwrap_err().code(), tonic::Code::Cancelled); + assert!(input_tx.is_closed(), "stdin receiver must not outlive exec"); + assert!(!fixture.eof.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn interactive_exec_parent_abort_drops_input_receiver() { + let fixture = Fixture::new().await; + let (input_tx, input_rx) = mpsc::channel(1); + let (output_tx, mut output_rx) = mpsc::channel(1); + let exec = tokio::spawn(run_interactive_exec_with_russh( + fixture.port, + "test", + ReceiverStream::new(input_rx), + false, + false, + 0, + 0, + output_tx, + )); + ready(&mut output_rx).await; + // An operation timeout drops the same future as this task abort. + exec.abort(); + assert!(exec.await.unwrap_err().is_cancelled()); + assert!( + input_tx.is_closed(), + "aborted exec must not leave a stdin task alive" + ); +} + +#[tokio::test] +async fn interactive_exec_response_drop_unblocks_full_output_queue() { + let fixture = Fixture::new().await; + let (input_tx, input_rx) = mpsc::channel(1); + let (output_tx, mut output_rx) = mpsc::channel(1); + // Use an empty request stream: EOF releases the peer's output once notified. + drop(input_tx); + let exec = run_interactive_exec_with_russh( + fixture.port, + "test", + ReceiverStream::new(input_rx), + false, + false, + 0, + 0, + output_tx, + ); + let exercise = async { + ready(&mut output_rx).await; + fixture.release_output.notify_one(); + while output_rx.is_empty() { + tokio::task::yield_now().await; + } + // stdout fills the one-slot queue while stderr still needs delivery. + drop(output_rx); + }; + let (result, ()) = tokio::time::timeout(Duration::from_secs(5), async { + tokio::join!(exec, exercise) + }) + .await + .unwrap(); + assert_eq!(result.unwrap_err().code(), tonic::Code::Cancelled); +} + +#[tokio::test] +async fn interactive_exec_early_exit_does_not_wait_for_stdin() { + let fixture = Fixture::new().await; + let (input_tx, input_rx) = mpsc::channel(1); + input_tx + .send(Ok(ExecSandboxInput { + payload: Some(exec_sandbox_input::Payload::Stdin(vec![ + b'x'; + 4 * 1024 * 1024 + ])), + })) + .await + .unwrap(); + let (output_tx, _output_rx) = mpsc::channel(1); + let result = tokio::time::timeout( + Duration::from_secs(5), + run_interactive_exec_with_russh( + fixture.port, + "early", + ReceiverStream::new(input_rx), + false, + false, + 0, + 0, + output_tx, + ), + ) + .await + .unwrap(); + assert_eq!(result.unwrap(), 0); + assert!(input_tx.is_closed()); +} diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 9d27e6039..f4cdd5e74 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -77,6 +77,10 @@ const NO_LOGIN_SHELL_ENV: (&str, &str) = ("OPENSHELL_NO_LOGIN_SHELL", "1"); const MAX_TEMPLATES_PER_WORKSPACE: u32 = 1000; const MAX_CREATE_SERVICE_EXPOSURES: usize = 32; +#[cfg(test)] +#[path = "interactive_exec_tests.rs"] +mod interactive_exec_tests; + #[derive(Debug)] pub struct WatchSandboxStream { receiver: ReceiverStream>, @@ -2975,7 +2979,7 @@ async fn stream_interactive_exec_over_relay( )), })) .await; - let _ = proxy_task.await; + finish_interactive_exec_proxy(proxy_task).await; return Ok(()); } } else { @@ -2985,12 +2989,12 @@ async fn stream_interactive_exec_over_relay( let exit_code = match exec_result { Ok(code) => code, Err(status) => { - let _ = proxy_task.await; + finish_interactive_exec_proxy(proxy_task).await; return Err(status); } }; - let _ = proxy_task.await; + finish_interactive_exec_proxy(proxy_task).await; let _ = tx .send(Ok(ExecSandboxEvent { @@ -3003,17 +3007,28 @@ async fn stream_interactive_exec_over_relay( Ok(()) } +async fn finish_interactive_exec_proxy(mut task: tokio::task::JoinHandle<()>) { + if tokio::time::timeout(EXEC_POST_EXIT_CLOSE_TIMEOUT, &mut task) + .await + .is_err() + { + task.abort(); + let _ = task.await; + } +} + #[allow(clippy::too_many_arguments)] async fn run_interactive_exec_with_russh( local_proxy_port: u16, command: &str, - mut input_stream: tonic::Streaming, + mut input_stream: impl futures::Stream> + Unpin, request_tty: bool, no_login_shell: bool, cols: u32, rows: u32, tx: mpsc::Sender>, ) -> Result { + use futures::StreamExt; use openshell_core::proto::exec_sandbox_input::Payload; use russh::ChannelMsg; @@ -3089,83 +3104,125 @@ async fn run_interactive_exec_with_russh( let (mut read_half, write_half) = channel.split(); - let stdin_task = tokio::spawn(async move { - while let Ok(Some(msg)) = input_stream.message().await { + // Keep both directions independently polled, but owned by this operation. + // A detached stdin task would survive operation timeout and retain the SSH + // channel. Normal request EOF closes stdin only, never the output channel. + let input = async { + while let Some(msg) = input_stream.next().await { + // Even ignored non-PTY resize frames must cooperate with output + // and cancellation when the request stream stays continuously ready. + tokio::task::consume_budget().await; + let msg = msg?; match msg.payload { Some(Payload::Stdin(data)) => { - if write_half.data(std::io::Cursor::new(data)).await.is_err() { - break; - } + write_half + .data(std::io::Cursor::new(data)) + .await + .map_err(|_| { + Status::unavailable("exec relay failed while writing stdin") + })?; } Some(Payload::Resize(resize)) => { if request_tty { - let _ = write_half + write_half .window_change(resize.cols, resize.rows, 0, 0) - .await; + .await + .map_err(|_| Status::unavailable("exec relay failed while resizing"))?; } } - Some(Payload::Start(_)) | None => {} + Some(Payload::Start(_)) | None => { + return Err(Status::invalid_argument( + "expected stdin or resize after exec start", + )); + } } } - let _ = write_half.eof().await; - let _ = write_half.close().await; - }); + write_half + .eof() + .await + .map_err(|_| Status::unavailable("exec relay failed while closing stdin")) + }; - let mut exit_code: Option = None; - loop { - // Bound the post-ExitStatus wait against a lost Close. - let msg = if exit_code.is_some() { - match tokio::time::timeout(EXEC_POST_EXIT_CLOSE_TIMEOUT, read_half.wait()).await { - Ok(Some(msg)) => msg, - Ok(None) | Err(_) => break, + let output = async { + let mut exit_code: Option = None; + loop { + // Bound the post-ExitStatus wait against a lost Close. + let msg = if exit_code.is_some() { + match tokio::time::timeout(EXEC_POST_EXIT_CLOSE_TIMEOUT, read_half.wait()).await { + Ok(Some(msg)) => msg, + Ok(None) | Err(_) => break, + } + } else { + match read_half.wait().await { + Some(msg) => msg, + None => break, + } + }; + match msg { + ChannelMsg::Data { data } => { + let event = Ok(ExecSandboxEvent { + payload: Some(openshell_core::proto::exec_sandbox_event::Payload::Stdout( + ExecSandboxStdout { + data: data.to_vec(), + }, + )), + }); + if tx.send(event).await.is_err() { + break; + } + } + ChannelMsg::ExtendedData { data, .. } => { + let event = Ok(ExecSandboxEvent { + payload: Some(openshell_core::proto::exec_sandbox_event::Payload::Stderr( + ExecSandboxStderr { + data: data.to_vec(), + }, + )), + }); + if tx.send(event).await.is_err() { + break; + } + } + ChannelMsg::ExitStatus { exit_status } => { + let converted = i32::try_from(exit_status).unwrap_or(i32::MAX); + exit_code = Some(converted); + } + ChannelMsg::Close => break, + _ => {} } - } else { - match read_half.wait().await { - Some(msg) => msg, - None => break, + } + + exec_loop_result(exit_code) + }; + + let result = { + tokio::pin!(input, output); + let exchange = async { + tokio::select! { + result = &mut input => { + result?; + output.await + } + result = &mut output => result, } }; - match msg { - ChannelMsg::Data { data } => { - let event = Ok(ExecSandboxEvent { - payload: Some(openshell_core::proto::exec_sandbox_event::Payload::Stdout( - ExecSandboxStdout { - data: data.to_vec(), - }, - )), - }); - if tx.send(event).await.is_err() { - break; - } - } - ChannelMsg::ExtendedData { data, .. } => { - let event = Ok(ExecSandboxEvent { - payload: Some(openshell_core::proto::exec_sandbox_event::Payload::Stderr( - ExecSandboxStderr { - data: data.to_vec(), - }, - )), - }); - if tx.send(event).await.is_err() { - break; - } - } - ChannelMsg::ExitStatus { exit_status } => { - let converted = i32::try_from(exit_status).unwrap_or(i32::MAX); - exit_code = Some(converted); - } - ChannelMsg::Close => break, - _ => {} + tokio::select! { + biased; + () = tx.closed() => Err(Status::cancelled("exec response stream closed")), + result = exchange => result, } - } + }; - stdin_task.abort(); + // EOF above deliberately leaves this channel open until output completes. + // Bound cleanup even if the SSH peer is no longer making progress. + let _ = tokio::time::timeout(EXEC_POST_EXIT_CLOSE_TIMEOUT, write_half.close()).await; + let _ = tokio::time::timeout( + EXEC_POST_EXIT_CLOSE_TIMEOUT, + client.disconnect(russh::Disconnect::ByApplication, "exec complete", "en"), + ) + .await; - let _ = client - .disconnect(russh::Disconnect::ByApplication, "exec complete", "en") - .await; - - exec_loop_result(exit_code) + result } /// Create a localhost SSH proxy that bridges to a relay `DuplexStream`. diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index c93f361e2..1eca60d93 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -719,16 +719,18 @@ impl russh::server::Handler for SshHandler { } } else if name == "sftp" { session.channel_success(channel)?; - // sftp-server speaks the SFTP binary protocol over stdin/stdout, - // which the boundary executor preserves as separate pipes. This enables - // modern scp (SFTP-based, OpenSSH 9.0+) and SFTP clients to - // transfer files into and out of the sandbox. + // The sandbox runtime implements SFTP over the boundary streams so + // the workload image does not need an sftp-server executable. self.start_exec_spec( channel, session.handle(), openshell_isolation_interface::contract::ExecSpec { - program: "/usr/lib/openssh/sftp-server".to_string(), - args: vec![], + program: String::new(), + args: Vec::new(), + shell: None, + runtime_helper: Some( + openshell_isolation_interface::contract::RuntimeHelper::Sftp, + ), env: vec![], workdir: None, pty: false, @@ -892,21 +894,6 @@ impl SshHandler { let no_login_shell = state.no_login_shell; let pty = state.pty_request.take(); let pty_requested = pty.is_some(); - let (program, args) = command.map_or_else( - || { - if pty_requested { - ("/bin/bash".to_string(), vec!["-i".to_string()]) - } else { - ("/bin/bash".to_string(), vec![]) - } - }, - |command| { - ( - "/bin/bash".to_string(), - vec![login_shell_flag(no_login_shell).to_string(), command], - ) - }, - ); let env = pty .as_ref() .map(|request| vec![("TERM".to_string(), request.term.clone())]) @@ -915,8 +902,13 @@ impl SshHandler { channel, handle, openshell_isolation_interface::contract::ExecSpec { - program, - args, + program: String::new(), + args: Vec::new(), + shell: Some(openshell_isolation_interface::contract::ShellSpec { + command, + login: !no_login_shell, + }), + runtime_helper: None, env, workdir: None, pty: pty_requested, @@ -1080,10 +1072,6 @@ async fn send_main_output(handle: &Handle, channel: ChannelId, event: MainOutput } } -const fn login_shell_flag(no_login_shell: bool) -> &'static str { - if no_login_shell { "-c" } else { "-lc" } -} - #[allow(dead_code)] #[derive(Clone)] struct PtyRequest { diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index ef361b8b1..03209e969 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1424,9 +1424,8 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender) { tokio::spawn(async move { let has_custom_image = !image.is_empty(); let template = if has_custom_image { - let resolved = openshell_core::image::resolve_community_image(&image); Some(openshell_core::proto::SandboxTemplate { - image: resolved, + image, ..Default::default() }) } else { diff --git a/deploy/docker/gateway.toml b/deploy/docker/gateway.toml index 1372bfc3f..c0cbda4ef 100644 --- a/deploy/docker/gateway.toml +++ b/deploy/docker/gateway.toml @@ -35,7 +35,7 @@ disable_tls = true [openshell.drivers.docker] # Default image pulled for `openshell sandbox create` without --from. -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" # Sandbox runtime image from which the openshell-sandbox binary is extracted. sandbox_runtime_image = "ghcr.io/nvidia/openshell/sandbox:latest" # Image containing the external supervisor process. diff --git a/deploy/helm/openshell/README.md b/deploy/helm/openshell/README.md index f0a96b143..160f543af 100644 --- a/deploy/helm/openshell/README.md +++ b/deploy/helm/openshell/README.md @@ -311,7 +311,7 @@ discovery endpoint or its TLS CA. | server.policyValidationFailureMode | string | `"fail_closed"` | Posture when a candidate sandbox policy fails validation. `fail_closed` deactivates the previous policy; `retain_last_valid` keeps it active. | | server.providerTokenGrants.spiffe.enabled | bool | `false` | Mount the SPIFFE Workload API socket into gateway and sandbox pods for dynamic provider token grants. | | server.providerTokenGrants.spiffe.workloadApiSocketPath | string | `"/spiffe-workload-api/spire-agent.sock"` | Path to the SPIFFE Workload API socket mounted into gateway and sandbox pods. | -| server.sandboxImage | string | `"ghcr.io/nvidia/openshell-community/sandboxes/base:latest"` | Default sandbox image used when requests do not specify one. | +| server.sandboxImage | string | `"nvcr.io/nvidia/base/ubuntu:24.04"` | Default sandbox image used when requests do not specify one. | | server.sandboxImagePullPolicy | string | `nil` | Pull policy for sandbox pods. Leave unset to use the Kubernetes image default (Always for :latest, IfNotPresent otherwise). Prefer always, if_not_present, or never; the chart also accepts legacy Kubernetes spellings Always, IfNotPresent, and Never. | | server.sandboxImagePullSecrets | list | `[]` | Image pull secrets attached to sandbox pods. Referenced Secrets must exist in the sandbox namespace. | | server.sandboxJwt.gatewayId | string | `""` | Stable gateway identity embedded in iss/aud of every minted token. Defaults to the release name so HA replicas share identity. | diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 67d5587ec..f395b7fa5 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -214,7 +214,7 @@ server: # `uri` key, e.g. postgresql://user:pass@host:5432/dbname. externalDbSecret: "" # -- Default sandbox image used when requests do not specify one. - sandboxImage: "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" + sandboxImage: "nvcr.io/nvidia/base/ubuntu:24.04" # -- Pull policy for sandbox pods. Leave unset to use the Kubernetes image # default (Always for :latest, IfNotPresent otherwise). Prefer always, # if_not_present, or never; the chart also accepts legacy Kubernetes spellings diff --git a/deploy/kube/manifests/openshell-helmchart.yaml b/deploy/kube/manifests/openshell-helmchart.yaml index 3ca6e3b90..f2c754033 100644 --- a/deploy/kube/manifests/openshell-helmchart.yaml +++ b/deploy/kube/manifests/openshell-helmchart.yaml @@ -29,7 +29,7 @@ spec: tag: latest pullPolicy: __IMAGE_PULL_POLICY__ server: - sandboxImage: ghcr.io/nvidia/openshell-community/sandboxes/base:latest + sandboxImage: nvcr.io/nvidia/base/ubuntu:24.04 sandboxImagePullPolicy: __SANDBOX_IMAGE_PULL_POLICY__ supervisorImage: ghcr.io/nvidia/openshell/supervisor:latest dbUrl: __DB_URL__ diff --git a/deploy/rpm/CONFIGURATION.md b/deploy/rpm/CONFIGURATION.md index 7ae1f6f15..ac57e02eb 100644 --- a/deploy/rpm/CONFIGURATION.md +++ b/deploy/rpm/CONFIGURATION.md @@ -220,7 +220,7 @@ overrides that persist across package upgrades. |-------------|---------|-------------| | `bind_address` | `127.0.0.1:17670` (gateway default) | Address for the primary gRPC/HTTP API listener. | | `compute_driver` | `"podman"` (RPM default) | When unset, the gateway auto-detects Kubernetes, then Podman, then Docker. The RPM default pins to Podman; legacy `compute_drivers` lists are rejected. | -| `[openshell.drivers.podman].default_image` | `ghcr.io/nvidia/openshell-community/sandboxes/base:latest` | Default sandbox image. | +| `[openshell.drivers.podman].default_image` | `nvcr.io/nvidia/base/ubuntu:24.04` | Default sandbox image. | | `[openshell.drivers.podman].sandbox_runtime_image` | `ghcr.io/nvidia/openshell/sandbox:latest` | Static musl sandbox runtime image mounted into Podman workloads. | | `[openshell.drivers.podman].supervisor_image` | `ghcr.io/nvidia/openshell/supervisor:latest` | Dynamic glibc supervisor image used outside the workload. | | `[openshell.gateway].guest_tls_ca`, `guest_tls_cert`, `guest_tls_key` | auto-generated paths | Gateway-owned client TLS material injected into the selected local driver and mounted into sandbox containers. | @@ -244,7 +244,7 @@ compute_driver = "podman" [openshell.drivers.podman] network_name = "openshell" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" image_pull_policy = "if_not_present" health_check_interval_secs = 10 stop_timeout_secs = 10 @@ -260,7 +260,7 @@ To update cached images: ```shell podman pull ghcr.io/nvidia/openshell/supervisor:latest -podman pull ghcr.io/nvidia/openshell-community/sandboxes/base:latest +podman pull nvcr.io/nvidia/base/ubuntu:24.04 ``` Or set `image_pull_policy = "always"` in @@ -272,7 +272,7 @@ To pin specific image versions instead of `:latest`, set these values in ```toml sandbox_runtime_image = "ghcr.io/nvidia/openshell/sandbox:v0.0.37" supervisor_image = "ghcr.io/nvidia/openshell/supervisor:v0.0.37" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:v0.0.37" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" ``` For air-gapped environments: @@ -281,9 +281,9 @@ For air-gapped environments: ```shell podman pull ghcr.io/nvidia/openshell/supervisor:latest - podman pull ghcr.io/nvidia/openshell-community/sandboxes/base:latest + podman pull nvcr.io/nvidia/base/ubuntu:24.04 podman save -o supervisor.tar ghcr.io/nvidia/openshell/supervisor:latest - podman save -o sandbox.tar ghcr.io/nvidia/openshell-community/sandboxes/base:latest + podman save -o sandbox.tar nvcr.io/nvidia/base/ubuntu:24.04 ``` 1. Transfer the tarballs to the air-gapped host and load them: diff --git a/deploy/rpm/TROUBLESHOOTING.md b/deploy/rpm/TROUBLESHOOTING.md index 29b27ad20..b9b2fdb56 100644 --- a/deploy/rpm/TROUBLESHOOTING.md +++ b/deploy/rpm/TROUBLESHOOTING.md @@ -176,7 +176,7 @@ sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER **Image pull failure.** Verify ghcr.io is reachable: ```shell -podman pull ghcr.io/nvidia/openshell-community/sandboxes/base:latest +podman pull nvcr.io/nvidia/base/ubuntu:24.04 ``` ### Images not updating @@ -185,7 +185,7 @@ The default image pull policy is `if_not_present` -- images are pulled once and cached. To update: ```shell -podman pull ghcr.io/nvidia/openshell-community/sandboxes/base:latest +podman pull nvcr.io/nvidia/base/ubuntu:24.04 podman pull ghcr.io/nvidia/openshell/supervisor:latest ``` @@ -239,7 +239,7 @@ To pick up new container images after an upgrade: ```shell podman pull ghcr.io/nvidia/openshell/supervisor:latest -podman pull ghcr.io/nvidia/openshell-community/sandboxes/base:latest +podman pull nvcr.io/nvidia/base/ubuntu:24.04 ``` ### Migrating a TLS-enabled local driver to schema version 2 diff --git a/docs/about/overview.mdx b/docs/about/overview.mdx index 90b565ae3..4c8a234e7 100644 --- a/docs/about/overview.mdx +++ b/docs/about/overview.mdx @@ -47,7 +47,7 @@ OpenShell supports a range of agent deployment patterns. | Secure coding agents | Run Claude Code, OpenCode, Codex, or GitHub Copilot CLI with constrained file and network access. | | Private enterprise development | Grant selected sandboxes access to self-hosted or private model endpoints while keeping sensitive context under your control. | | Compliance and audit | Treat policy YAML as version-controlled security controls that can be reviewed and audited. | -| Reusable environments | Use community sandbox images or bring your own containerized runtime. | +| Reusable environments | Use the default Ubuntu workload or bring your own containerized runtime. | ## Next Steps diff --git a/docs/about/release-notes.mdx b/docs/about/release-notes.mdx index 0b774e541..3b9d3f561 100644 --- a/docs/about/release-notes.mdx +++ b/docs/about/release-notes.mdx @@ -12,6 +12,23 @@ NVIDIA OpenShell follows a frequent release cadence. Use the following GitHub re ## 0.1.0 migration notes +### Workload images + +The default workload image is now `nvcr.io/nvidia/base/ubuntu:24.04`. It is a +minimal Ubuntu Noble environment and does not include agent CLIs or an +image-baked OpenShell policy. OpenShell applies its built-in restrictive policy +when no explicit policy is supplied. + +The CLI no longer expands bare catalog names or supports the former catalog +registry override. Build or select an OCI image and pass its explicit reference: + +```shell +openshell sandbox create --from registry.example.com/agents/my-agent:1.0 -- my-agent +``` + +Gateway operators can replace the default with the driver configuration or the +Helm `server.sandboxImage` value. + ### Provider profiles are import-only OpenShell 0.1.0 stops compiling provider profiles into the gateway binary. A gateway's catalog contains exactly the profiles an operator imported, the default `provider_profile_sources` is `[{ type = "user" }]`, and the `builtin` source type is removed: a `gateway.toml` that still names it is rejected at startup with the import command. A gateway with nothing imported reaches ready and serves an empty catalog. @@ -34,7 +51,7 @@ A provider whose profile is absent after the upgrade stays listable and exportab Command-to-provider inference is removed. `openshell sandbox create -- ` no longer derives a provider from the trailing command, and the compiled alias table that drove it is gone. A profile's `binaries` list authorizes a binary to reach that profile's endpoints; it is not a statement that running the binary asks for the provider, so treating it as attachment intent could attach credentials the operator did not select. Name providers explicitly with `--provider `, which still creates a missing provider from local discovery when the name matches an imported profile ID. -The files under `providers/` are reviewable examples, not defaults. Read each file's header before importing it: several bind network access to binary paths that exist only in the OpenShell Community image, and imported unchanged into another image the profile matches nothing. +The files under `providers/` are reviewable examples, not defaults. Read each file's header before importing it: several bind network access to binary paths from the image layout where they were authored, and imported unchanged into another image the profile matches nothing. ### Network policy binaries diff --git a/docs/about/supported-agents.mdx b/docs/about/supported-agents.mdx index 13704aa94..601a91ebe 100644 --- a/docs/about/supported-agents.mdx +++ b/docs/about/supported-agents.mdx @@ -6,19 +6,17 @@ description: "AI agent frameworks and runtimes compatible with OpenShell sandbox keywords: "Generative AI, Cybersecurity, AI Agents, Sandboxing, Claude, Codex, Cursor" position: 5 --- -The following table summarizes the agents that run in OpenShell sandboxes. Most agent sandbox images are maintained in the [OpenShell Community](https://github.com/NVIDIA/OpenShell-Community) repository. Agents in the base image are auto-configured when passed as the trailing command to `openshell sandbox create`. -| Agent | Source | Default Policy | Notes | -|---|---|---|---| -| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | Full coverage | Works out of the box with an attached `claude-code` provider. For another model platform, attach an endpoint-bearing profile and configure Claude Code for that platform's native API. | -| [OpenCode](https://opencode.ai/) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | Partial coverage | Pre-installed. Attach the selected model provider, configure its native base URL and model, and ensure the imported profile names OpenCode's service endpoint and binary paths. | -| [Codex](https://developers.openai.com/codex) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | No coverage | Pre-installed. Requires a custom policy with OpenAI endpoints and Codex binary paths. Requires `OPENAI_API_KEY`. | -| [GitHub Copilot CLI](https://docs.github.com/en/copilot/github-copilot-in-the-cli) | [`base`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) | Full coverage | Pre-installed. Works out of the box. Requires `GITHUB_TOKEN` or `COPILOT_GITHUB_TOKEN`. | -| [OpenClaw](https://openclaw.ai/) | [NemoClaw](https://github.com/NVIDIA/NemoClaw) | Blueprint-managed | Run OpenClaw more securely inside NVIDIA OpenShell with the NemoClaw blueprint. | -| [Hermes Agent](https://github.com/NousResearch/hermes-agent) | [NemoClaw](https://github.com/NVIDIA/NemoClaw) | Blueprint-managed | Run Hermes Agent more securely inside NVIDIA OpenShell with the NemoClaw blueprint. | -| [Ollama](https://ollama.com/) | [`ollama`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/ollama) | Bundled | Run cloud and local models. Includes Claude Code, Codex, and OpenCode. Launch with `openshell sandbox create --from ollama`. | -| [Pi](https://pi.dev/) | [`pi`](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/pi) | Bundled | Comes with Pi pre-installed. Launch with `openshell sandbox create --from pi`. | +OpenShell can run containerized agents that support Linux. The default Ubuntu workload image does not bundle agent CLIs. Build or select an OCI image containing the agent, pass its explicit image reference to `--from`, and attach the providers and policy it needs. -For base image details and `--from` usage, refer to [Sandboxes](/sandboxes/manage-sandboxes#base-sandbox-container). +| Agent | Image workflow | Provider and policy requirements | +|---|---|---| +| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | Add Claude Code to your workload image. | Attach a `claude-code` provider or an endpoint-bearing profile for the selected model platform. Authorize the agent binary and service endpoints. | +| [OpenCode](https://opencode.ai/) | Add OpenCode to your workload image. | Attach the selected model provider and authorize OpenCode's binary paths and service endpoints. | +| [Codex](https://developers.openai.com/codex) | Add Codex to your workload image. | Attach an OpenAI provider and authorize the Codex binary paths and OpenAI endpoints. | +| [GitHub Copilot CLI](https://docs.github.com/en/copilot/github-copilot-in-the-cli) | Add GitHub Copilot CLI to your workload image. | Attach GitHub credentials and authorize the CLI binary and required GitHub endpoints. | +| [OpenClaw](https://openclaw.ai/) | Use the [NemoClaw](https://github.com/NVIDIA/NemoClaw) blueprint. | The blueprint manages the workload and its policy. | +| [Hermes Agent](https://github.com/NousResearch/hermes-agent) | Use the [NemoClaw](https://github.com/NVIDIA/NemoClaw) blueprint. | The blueprint manages the workload and its policy. | +| [Ollama](https://ollama.com/) | Build an image containing Ollama, or connect a sandbox to a separately managed Ollama service. | Authorize the client binary and the Ollama endpoint. Refer to [Inference with Ollama](/get-started/tutorials/inference-ollama). | -For a complete support matrix, refer to the [Support Matrix](/reference/support-matrix) page. +For image selection and `--from` usage, refer to [Manage Sandboxes](/sandboxes/manage-sandboxes#sandbox-images). For a complete platform matrix, refer to [Support Matrix](/reference/support-matrix). diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 91565d71a..55b1eb3ab 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -64,66 +64,37 @@ If your sandbox image installs the agent somewhere else, edit `binaries` first otherwise the profile matches nothing and the credential is never injected. -## Create Your First OpenShell Sandbox +## Create Your First Sandbox -Create a sandbox and launch an agent inside it. -Choose the tab that matches your agent: - - - - -Run the following command to create a sandbox with Claude Code: +Create a sandbox with the gateway's default workload image: ```shell -openshell sandbox create --provider claude-code -- claude +openshell sandbox create --name quickstart ``` -`--provider` names the profile to use. If no provider by that name exists yet, -the CLI prompts you to create one from local credentials; type `yes` to continue. -If `ANTHROPIC_API_KEY` is set in your environment, the CLI picks it up automatically. -If not, you can configure it from inside the sandbox after it launches. +The default is `nvcr.io/nvidia/base/ubuntu:24.04`. It provides a minimal Ubuntu Noble environment; install workload-specific tools in your own image. - -`ANTHROPIC_API_KEY` is an API key from [console.anthropic.com](https://console.anthropic.com), not a subscription token. Subscription users must generate a separate API key. - - - - - -Run the following command to create a sandbox with OpenCode: +Connect to the sandbox: ```shell -openshell sandbox create --provider openai -- opencode +openshell sandbox connect quickstart ``` -`--provider` names the profile to use. If no provider by that name exists yet, -the CLI prompts you to create one from local credentials; type `yes` to continue. -If `OPENAI_API_KEY` or `OPENROUTER_API_KEY` is set in your environment, the CLI picks it up automatically. -If not, you can configure it from inside the sandbox after it launches. - - - - -Run the following command to create a sandbox with Codex: +Run a command without opening an interactive session: ```shell -openshell sandbox create --provider codex -- codex +openshell sandbox exec -n quickstart -- cat /etc/os-release ``` -`--provider` names the profile to use. If no provider by that name exists yet, -the CLI prompts you to create one from local credentials; type `yes` to continue. -If `OPENAI_API_KEY` is set in your environment, the CLI picks it up automatically. -If not, you can configure it from inside the sandbox after it launches. - +## Run an Agent Image - - -Use the `--from` flag to create a sandbox from the base container: +Build an OCI image containing your agent and its dependencies, then pass its full image reference: ```shell -openshell sandbox create --from base +docker build -t my-agent:latest ./my-agent +openshell sandbox create --from my-agent:latest -- my-agent ``` - +For a Podman gateway, build and use a Podman-visible name such as `localhost/my-agent:latest`. For a remote gateway, push the image to a registry that the gateway can pull from. - +Attach a provider and a policy that authorize the agent's credentials, endpoints, and executable paths. Refer to [Bring Your Own Container](https://github.com/NVIDIA/OpenShell/tree/main/examples/bring-your-own-container), [Providers](/providers), and [Customize Sandbox Policies](/sandboxes/policies). diff --git a/docs/get-started/tutorials/docker-compose.mdx b/docs/get-started/tutorials/docker-compose.mdx index 1efc2abfe..33f6b0c1d 100644 --- a/docs/get-started/tutorials/docker-compose.mdx +++ b/docs/get-started/tutorials/docker-compose.mdx @@ -137,40 +137,38 @@ Confirm the provider was stored: openshell provider list ``` -## Pre-pull sandbox images (optional) +## Pre-pull workload images (optional) -Sandbox images are pulled automatically on first use, but the initial pull can take several minutes for large images. Pre-pull to avoid long waits at sandbox creation time: +Workload images are pulled automatically on first use. Pre-pull the default +Ubuntu workload or your own agent image to avoid waiting during sandbox +creation: ```shell -# Base image — includes Claude Code, OpenCode, Codex, and Copilot -docker pull ghcr.io/nvidia/openshell-community/sandboxes/base:latest +docker pull nvcr.io/nvidia/base/ubuntu:24.04 +docker pull registry.example.com/your-org/agent:latest ``` -## Create a sandbox +## Create a Sandbox - - - -OpenClaw runs inside OpenShell through [NemoClaw](https://github.com/NVIDIA/NemoClaw), which manages the sandbox image, model-provider setup, and security policies. - -Follow the [NemoClaw Quickstart](https://docs.nvidia.com/nemoclaw/latest/get-started/quickstart/) to set up an OpenClaw sandbox. - - - +Create a minimal Ubuntu sandbox: ```shell -openshell sandbox create -- claude +openshell sandbox create --name compose-demo ``` - - +To run an agent, use an explicit OCI image that contains it: ```shell -openshell sandbox create -- opencode +openshell sandbox create \ + --from registry.example.com/your-org/agent:latest \ + --provider \ + -- my-agent ``` - - +OpenClaw runs through [NemoClaw](https://github.com/NVIDIA/NemoClaw), which +manages its sandbox image, model provider, and security policies. Follow the +[NemoClaw Quickstart](https://docs.nvidia.com/nemoclaw/latest/get-started/quickstart/) +for that workflow. Wait for the phase to change from `Provisioning` to `Ready`: diff --git a/docs/get-started/tutorials/github-sandbox.mdx b/docs/get-started/tutorials/github-sandbox.mdx index e420bb697..6be5b3445 100644 --- a/docs/get-started/tutorials/github-sandbox.mdx +++ b/docs/get-started/tutorials/github-sandbox.mdx @@ -57,7 +57,7 @@ openshell provider profile import -f github.yaml --global GITHUB_TOKEN= openshell provider create --name my-github --type github --from-existing -openshell sandbox create --provider my-github -- claude +openshell sandbox create --from registry.example.com/your-org/claude-agent:latest --provider my-github -- claude ``` The import step registers the `github` profile on your gateway. OpenShell ships diff --git a/docs/get-started/tutorials/index.mdx b/docs/get-started/tutorials/index.mdx index 2050480b9..cda35bfff 100644 --- a/docs/get-started/tutorials/index.mdx +++ b/docs/get-started/tutorials/index.mdx @@ -29,7 +29,7 @@ Configure a Microsoft Graph provider profile with gateway-managed OAuth2 refresh -Run Ollama models in a community sandbox or attach a profile for a host-level service. +Attach a profile that authorizes a sandbox to use a host-level Ollama service. diff --git a/docs/get-started/tutorials/inference-ollama.mdx b/docs/get-started/tutorials/inference-ollama.mdx index c23e6eaf6..017e8ec88 100644 --- a/docs/get-started/tutorials/inference-ollama.mdx +++ b/docs/get-started/tutorials/inference-ollama.mdx @@ -4,65 +4,19 @@ title: "Run Local Inference with Ollama" sidebar-title: "Inference with Ollama" slug: "get-started/tutorials/inference-ollama" -description: "Run Ollama inside a community sandbox or attach a profile for a host-level Ollama server." +description: "Authorize an OpenShell sandbox to use an Ollama service running on the gateway host." keywords: "Generative AI, Cybersecurity, Tutorial, Ollama, Local Inference, Sandbox, Provider Profile" --- -This tutorial covers two Ollama deployments: - -1. The self-contained Ollama community sandbox. This is the recommended path. -2. A host-level Ollama service shared by explicitly authorized sandboxes. +This tutorial connects an explicitly authorized sandbox to an Ollama service running on the same machine as the OpenShell gateway. ## Prerequisites -Complete the [Quickstart](/get-started/quickstart) before proceeding. +Complete the [Quickstart](/get-started/quickstart) before proceeding. Your workload image must contain the client or agent you plan to use. -## Option A: Ollama Community Sandbox +## Start Ollama on a Reachable Address -The community image bundles Ollama and supported coding agents: - -```shell -openshell sandbox create --from ollama -``` - -Inside the sandbox, run a local or cloud model: - -```shell -ollama run qwen3.5 -ollama run kimi-k2.5:cloud -``` - -Or launch a coding agent against Ollama: - -```shell -ollama launch claude -ollama launch codex -ollama launch opencode -``` - -For an automated workflow: - -```shell -ollama launch claude --yes --model qwen3.5 -``` - -Useful starter models include `qwen3.5:0.8b` for smoke tests and `qwen3.5` -for coding and tool use. Check the -[Ollama model library](https://ollama.com/library) for current model details. - -Update the bundled Ollama installation with `update-ollama`, or request an -update at sandbox start: - -```shell -openshell sandbox create --from ollama -e OLLAMA_UPDATE=1 -``` - -## Option B: Host-level Ollama - -Use this path when Ollama runs on the same machine as the OpenShell gateway and -selected sandboxes should share it. - -### Start Ollama on a Reachable Address +Install Ollama on the gateway host and listen on an address reachable from sandboxes: ```shell curl -fsSL https://ollama.com/install.sh | sh @@ -75,7 +29,7 @@ In another terminal, pull a model: ollama pull qwen3.5:0.8b ``` -### Import an Endpoint-bearing Profile +## Import an Endpoint-bearing Profile Save this as `ollama-openai.yaml`: @@ -107,14 +61,16 @@ openshell provider profile import -f ollama-openai.yaml openshell provider create --name ollama --type ollama-openai ``` -The provider has no secret because this Ollama server does not authenticate. -Its attachment still carries the endpoint and binary policy. +The provider has no secret because this Ollama server does not authenticate. Its attachment still carries the endpoint and binary policy. -### Attach and Verify +## Attach and Verify + +Use an explicit workload image containing `curl`: ```shell openshell sandbox create \ --name ollama-client \ + --from registry.example.com/tools/curl:latest \ --provider ollama \ -- \ curl http://host.openshell.internal:11434/v1/chat/completions \ @@ -125,9 +81,7 @@ openshell sandbox create \ }' ``` -For an OpenAI SDK client, set its base URL to -`http://host.openshell.internal:11434/v1`, use any non-empty API key value the -SDK accepts, and select the real Ollama model in the request. +For an OpenAI SDK client, set its base URL to `http://host.openshell.internal:11434/v1`, use any non-empty API key value the SDK accepts, and select the real Ollama model in the request. ## Troubleshooting @@ -136,8 +90,7 @@ SDK accepts, and select the real Ollama model in the request. - Confirm attachment with `openshell sandbox provider list ollama-client`. - Inspect the effective policy with `openshell policy get ollama-client --full`. - Run `ollama ps` and `ollama pull ` when the model is unavailable. -- If the gateway is remote, the hostname refers to the remote gateway host, - not your laptop. Use a shared service address or tunnel instead. +- If the gateway is remote, the hostname refers to the remote gateway host, not your laptop. Use a shared service address or tunnel instead. ## Next Steps diff --git a/docs/index.mdx b/docs/index.mdx index b7623332e..9a40bca09 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -49,7 +49,7 @@ Install OpenShell and create your first sandbox in two commands. ```shell curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh -openshell sandbox create -- claude +openshell sandbox create --name quickstart ``` @@ -86,7 +86,7 @@ Hands-on walkthroughs from first sandbox to custom policies. -Deploy gateways, create sandboxes, configure policies, providers, and community images for your AI agents. +Deploy gateways, create sandboxes, configure policies, providers, and workload images for your AI agents. Concept diff --git a/docs/providers/profiles.mdx b/docs/providers/profiles.mdx index 1840e3a06..d902a6963 100644 --- a/docs/providers/profiles.mdx +++ b/docs/providers/profiles.mdx @@ -257,10 +257,10 @@ inference and agent providers in reviewable starting points, not platform defaults. Read a file's header before importing it. Each one names the client binaries it -expects, the image layout those paths assume, the credential scope, the endpoint -access it grants, and a smoke test. Several name paths that exist only in the -OpenShell Community image, such as `/sandbox/.venv` or -`/usr/lib/node_modules/@openai`. Imported unchanged into a different image the +expects, the reference image layout those paths assume, the credential scope, +the endpoint access it grants, and a smoke test. Several name layout-specific +paths, such as `/sandbox/.venv` or `/usr/lib/node_modules/@openai`. Imported +unchanged into a different image the profile matches nothing: the catalog still lists it, but the credential is never injected and the traffic is denied. Copy the file, edit `binaries` and `endpoints` for your image and workload, and import your copy. diff --git a/docs/reference/default-policy.mdx b/docs/reference/default-policy.mdx index 8e4dd015a..7a45be7d3 100644 --- a/docs/reference/default-policy.mdx +++ b/docs/reference/default-policy.mdx @@ -7,14 +7,16 @@ description: "Breakdown of the built-in default policy applied when you create a keywords: "Generative AI, Cybersecurity, AI Agents, Sandboxing, Security, Policy" position: 2 --- -The default policy is the policy applied when you create an OpenShell sandbox without `--policy`. It is baked into the community base image ([`ghcr.io/nvidia/openshell-community/sandboxes/base`](https://github.com/NVIDIA/OpenShell-Community)) and defined in the community repo's `dev-sandbox-policy.yaml`. -## How the Image Policy Becomes Active +When you create a sandbox without `--policy`, OpenShell applies a restrictive built-in fallback. The policy comes from the OpenShell runtime and does not depend on the selected workload image. -On the first supervisor connection, the supervisor offers the policy found in -the sandbox image to the gateway. If the gateway already has a policy for the -sandbox, the gateway policy takes precedence. Otherwise, the gateway uses the -image policy as the initial candidate. +## How an Image Policy Becomes Active + +If the selected workload image contains a policy, the supervisor offers it to +the gateway on the first connection. If the gateway already has a policy for +the sandbox, the gateway policy takes precedence. Otherwise, the gateway uses +the image policy as the initial candidate. If the image has no policy, the +gateway uses the built-in fallback. The gateway sends the selected policy back to the supervisor on the same connection. The supervisor adds filesystem paths that exist in that image, then @@ -27,21 +29,25 @@ The supervisor initializes from that bootstrap. It does not initialize from the image policy or the preparation response. Reconnects skip image-policy preparation and use the current gateway configuration. -## Agent Compatibility +## Filesystem Access -The following table shows the coverage of the default policy for common agents. +The fallback includes the sandbox working directory and grants read-only access to standard runtime paths: -| Agent | Coverage | Action Required | -|---|---|---| -| Claude Code | Full | None. Works out of the box. | -| OpenCode | Partial | Add `opencode.ai` endpoint and OpenCode binary paths. | -| Codex | None | Provide a complete custom policy with OpenAI endpoints and Codex binary paths. | +- `/usr` +- `/lib` +- `/proc` +- `/dev/urandom` +- `/etc` +- `/var/log` - -If you run a non-Claude agent without a custom policy, the agent's API calls are denied by the proxy. You must provide a policy that declares the agent's endpoints and binaries. +It grants read-write access to `/tmp` and `/dev/null`. Landlock enforcement uses `best_effort` compatibility so OpenShell can use the strongest ABI available on the host while retaining its mandatory baseline protections. - +## Network Access -## Default Policy Blocks +The fallback defines no network policies or provider-derived endpoints, so outbound network access is denied. Attach a provider or apply a custom policy that names the required endpoints and executable paths before running a networked agent. -The default policy blocks are defined in the community base image. Refer to the [OpenShell Community repository](https://github.com/NVIDIA/OpenShell-Community) for the full `dev-sandbox-policy.yaml` source. +## Process Identity + +The fallback leaves process identity selection to the compute driver. Docker and Podman honor a non-root OCI `USER`; when an image declares no user, they use numeric UID and GID `1000`. Kubernetes and MicroVM drivers apply their configured non-root identities. + +Use `openshell policy get --full` to inspect the effective policy. Refer to [Customize Sandbox Policies](/sandboxes/policies) to replace the fallback. diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index b6a1ca487..a6fd5f2de 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -252,7 +252,7 @@ phases = ["validate"] namespace = "openshell" # Required in raw TOML; Helm derives this from the gateway Service. grpc_endpoint = "https://openshell-gateway.openshell.svc:8080" -default_image = "ghcr.io/nvidia/openshell/sandbox:latest" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" # Defaults to the gateway version. Gateway and supervisor require matching # protocol revision 3 and stream authoritative configuration snapshots. # Peers that require configuration polling are rejected. @@ -581,7 +581,7 @@ workspace_mode = "shared" # gateway_id = "openshell" namespace = "agents" service_account_name = "openshell-sandbox" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" image_pull_policy = "if_not_present" image_pull_secrets = ["regcred"] # Defaults to the gateway version; override to pin a specific build. @@ -734,7 +734,7 @@ guest_tls_key = "/etc/openshell/certs/client-key.pem" [openshell.drivers.docker] socket_path = "/var/run/docker.sock" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" # Canonical values: always | if_not_present | never. `newer` is Podman-only. image_pull_policy = "if_not_present" # Value assigned to the openshell.sandbox_namespace label on sandbox containers. @@ -795,7 +795,7 @@ network_name = "openshell" # asks the podman CLI where its socket is, and fails to start if neither finds # one. Set this to pin a specific Podman machine instead. socket_path = "/run/user/1000/podman/podman.sock" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" image_pull_policy = "if_not_present" # always | if_not_present | never | newer # Optional override. When omitted, Linux uses gateway loopback and Podman # Machine uses host.containers.internal. diff --git a/docs/reference/support-matrix.mdx b/docs/reference/support-matrix.mdx index 740b5c997..c587ee6a9 100644 --- a/docs/reference/support-matrix.mdx +++ b/docs/reference/support-matrix.mdx @@ -71,22 +71,22 @@ Install the software for the compute driver you use: | kubectl | Compatible with your cluster | Required for Kubernetes operational inspection and secret creation. | | Host virtualization | Host dependent | Required for MicroVM-backed gateways. MicroVM uses Hypervisor.framework on macOS and KVM on Linux. | -## Sandbox Runtime Versions +## Workload Images -Sandbox container images are maintained in the [OpenShell Community](https://github.com/NVIDIA/OpenShell-Community) repository. Refer to that repository for the current list of installed components and their versions. +OpenShell accepts standard Linux OCI images. The built-in default workload is +`nvcr.io/nvidia/base/ubuntu:24.04` for `linux/amd64` and `linux/arm64`. It is a +minimal Ubuntu Noble userspace and does not bundle agent CLIs. Operators can +change the default, and users can select an explicit image with `--from`. ## Container Images -OpenShell publishes the gateway image for `linux/amd64` and `linux/arm64`. - | Image | Reference | Pulled When | |---|---|---| | Gateway | `ghcr.io/nvidia/openshell/gateway:latest` | Helm chart install or upgrade, or standalone container deployment | +| Default workload | `nvcr.io/nvidia/base/ubuntu:24.04` | First sandbox creation unless preloaded or overridden | The Helm chart in `deploy/helm/openshell` deploys the gateway workload, service account, service, optional persistent storage, and network policy for Kubernetes. It defaults to a StatefulSet for SQLite-backed installs and can render a Deployment for external database-backed installs. -Sandbox images are maintained separately in the [OpenShell Community](https://github.com/NVIDIA/OpenShell-Community) repository. - To override the default image references, use Helm values: | Helm value | Purpose | diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index 5a281a0d0..a1837c0a0 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -322,7 +322,7 @@ with an error after it attempts every requested deletion if any entry failed. Pass one or more `--provider` flags when creating a sandbox: ```shell -openshell sandbox create --provider my-claude --provider my-github -- claude +openshell sandbox create --from registry.example.com/your-org/claude-agent:latest --provider my-claude --provider my-github -- claude ``` Each `--provider` flag attaches one provider. The sandbox receives eligible @@ -348,7 +348,7 @@ name matches an imported profile ID, the CLI creates one from local profile discovery, so you do not need to create it separately: ```shell -openshell sandbox create --provider claude-code -- claude +openshell sandbox create --from registry.example.com/your-org/claude-agent:latest --provider claude-code -- claude ``` This finds your `ANTHROPIC_API_KEY`, creates a `claude-code` provider, attaches @@ -485,5 +485,5 @@ Explore related topics: - To manage workspace access for providers, refer to [Manage Workspaces and Access](/sandboxes/manage-workspaces). - To control what the agent can access, refer to [Policies](/sandboxes/policies). -- To use the base sandbox container, refer to [Sandboxes](/sandboxes/manage-sandboxes#base-sandbox-container). +- To use the default workload image, refer to [Sandboxes](/sandboxes/manage-sandboxes#default-workload-image). - To view the complete field reference for the policy YAML, refer to the [Policy Schema Reference](/reference/policy-schema). diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 056332c37..3cf3fdf83 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -17,7 +17,7 @@ A sandbox is the OpenShell data plane: a safe, private execution environment whe Create a sandbox with a single command. For example, to create a sandbox with Claude, run: ```shell -openshell sandbox create -- claude +openshell sandbox create --from registry.example.com/your-org/claude-agent:latest -- claude ``` The trailing command is the sandbox's canonical main process. OpenShell starts @@ -80,7 +80,7 @@ openshell gateway select local Set per-sandbox CPU and memory amounts with `--cpu` and `--memory`: ```shell -openshell sandbox create --cpu 2 --memory 4Gi -- claude +openshell sandbox create --from registry.example.com/your-org/claude-agent:latest --cpu 2 --memory 4Gi -- claude ``` CPU values use Kubernetes-style quantities such as `500m`, `1`, or `2.5`. @@ -115,13 +115,13 @@ the same behavior. To request GPU resources, add `--gpu`: ```shell -openshell sandbox create --gpu -- claude +openshell sandbox create --gpu --from registry.example.com/your-org/gpu-agent:latest -- claude ``` Request a specific number of GPUs by passing a count to `--gpu`: ```shell -openshell sandbox create --gpu 2 -- claude +openshell sandbox create --gpu 2 --from registry.example.com/your-org/gpu-agent:latest -- claude ``` When you omit the count, OpenShell treats the request as `--gpu 1`. @@ -153,22 +153,22 @@ openshell sandbox create \ -- claude ``` -### Custom Containers +### Sandbox Images -Use `--from` to create a sandbox from the base image, another pre-built sandbox name, a rootfs tar archive, or a container image: +Without `--from`, the gateway uses its configured default workload image. The +built-in default is `nvcr.io/nvidia/base/ubuntu:24.04`. + +Use `--from` with an explicit OCI image reference or a rootfs tar archive: ```shell -openshell sandbox create --from base -openshell sandbox create --from ollama -openshell sandbox create --from ./rootfs.tar +openshell sandbox create --from nvcr.io/nvidia/base/ubuntu:24.04 openshell sandbox create --from my-registry.example.com/my-image:latest +openshell sandbox create --from ./rootfs.tar ``` -Bare names such as `base` and `ollama` resolve to images under `ghcr.io/nvidia/openshell-community/sandboxes`. Set `OPENSHELL_COMMUNITY_REGISTRY` when you need to use an internal mirror. - -`--from` does not build local Dockerfiles or directories. Build and tag the -image with the container engine used by your local gateway, then pass the -resulting image reference: +`--from` does not expand catalog aliases and does not build local Dockerfiles or +directories. Build and tag the image with the container engine used by your +local gateway, then pass the resulting image reference: ```shell # Docker gateway @@ -185,8 +185,8 @@ from and use that registry image reference. **Pre-0.1.0 breaking change:** `openshell sandbox create --from ./Dockerfile` -and directory sources no longer build images. Build and tag the image before -you create the sandbox. +and directory sources no longer build images. Bare catalog names are no longer +expanded. Build or select an image and pass its explicit reference. #### Rootfs Tar Archives @@ -277,17 +277,27 @@ For JSON or YAML, template list output contains `templates` and `next_page_token` fields. Pass the returned token to `--page-token` to continue. -## Base Sandbox Container +## Default Workload Image -The `base` sandbox container is the default runtime image for standard OpenShell sandboxes unless the gateway overrides its default sandbox image. It is published as `ghcr.io/nvidia/openshell-community/sandboxes/base:latest` and maintained in the [OpenShell Community](https://github.com/NVIDIA/OpenShell-Community/tree/main/sandboxes/base) repository. +OpenShell defaults to `nvcr.io/nvidia/base/ubuntu:24.04` unless the gateway +operator configures another image. The image provides a minimal Ubuntu Noble +userspace. It does not include agent CLIs or an image-baked OpenShell policy. +OpenShell applies its built-in restrictive policy when no explicit policy is +provided. -The base container includes common development tooling, supported agent CLIs, and the default sandbox policy. Use it when you want a general-purpose agent environment without a workflow-specific image: +Create a sandbox with the default image: ```shell -openshell sandbox create --from base +openshell sandbox create ``` -For default policy coverage by agent, refer to [Default Policy](/reference/default-policy). For the supported agent list, refer to [Supported Agents](/about/supported-agents). +Override it with any image visible to the active compute driver: + +```shell +openshell sandbox create --from registry.example.com/agents/my-agent:1.0 +``` + +Refer to [Default Policy](/reference/default-policy), [Supported Agents](/about/supported-agents), and the [BYOC example](https://github.com/NVIDIA/OpenShell/tree/main/examples/bring-your-own-container). ## Connect to a Sandbox @@ -319,6 +329,12 @@ OpenShell-managed SSH include file instead of cluttering your main ## Execute a Command in a Sandbox +For raw `ExecSandboxInteractive` clients, ending the request stream closes stdin +and terminal resize input. Continue reading the response to drain stdout/stderr +and receive the exit event and final gRPC status. Input EOF does not immediately +close the SSH output channel and is distinct from cancelling the RPC. With a PTY, +input closure is not equivalent to sending a terminal Ctrl-D keystroke. + Run a one-shot command inside a running sandbox without opening an interactive shell: ```shell @@ -390,7 +406,7 @@ Environment variable names starting with `OPENSHELL_` are reserved. Keys must ma Attach labels when you create a sandbox to track ownership, environment, or workflow grouping: ```shell -openshell sandbox create --label env=dev --label team=platform -- claude +openshell sandbox create --from registry.example.com/your-org/claude-agent:latest --label env=dev --label team=platform -- claude ``` List only the sandboxes that match a label selector: @@ -431,7 +447,7 @@ with SandboxClient.from_active_cluster() as client: templates.create( workspace="default", name="python", - image="ghcr.io/nvidia/openshell-community/sandboxes/python:latest", + image="registry.example.com/agents/python:latest", ) sandbox = client.create_from_template(workspace="default", workload_template="python") @@ -711,7 +727,7 @@ The default table colorizes the `STATUS` column only when both standard output a You can also forward a port at creation time with `--forward`: ```shell -openshell sandbox create --forward 8000 -- claude +openshell sandbox create --from registry.example.com/your-org/claude-agent:latest --forward 8000 -- claude ``` @@ -763,7 +779,7 @@ You can also upload files at creation time with the `--upload` flag on several paths in a single command: ```shell -openshell sandbox create --upload ./src:/workspace/src --upload ./config:/workspace/config -- claude +openshell sandbox create --from registry.example.com/your-org/claude-agent:latest --upload ./src:/workspace/src --upload ./config:/workspace/config -- claude ``` @@ -895,4 +911,4 @@ For Docker, Podman, MicroVM, and Kubernetes behavior, refer to [Sandbox Compute - To select a workspace or understand access roles, refer to [Manage Workspaces and Access](/sandboxes/manage-workspaces). - To supply API keys or tokens, refer to [Manage Providers](/sandboxes/manage-providers). - To control what the agent can access, refer to [Policies](/sandboxes/policies). -- To use the default runtime image, refer to [Base Sandbox Container](#base-sandbox-container). +- To use the default runtime image, refer to [Default Workload Image](#default-workload-image). diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index d2b4558b1..20b1d6838 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -179,7 +179,7 @@ The first TCP endpoint is a deliberate exception to ordinary dynamic network pol Pass a policy YAML file when creating the sandbox: ```shell -openshell sandbox create --policy ./my-policy.yaml -- claude +openshell sandbox create --from registry.example.com/your-org/claude-agent:latest --policy ./my-policy.yaml -- claude ``` The trailing command is the sandbox's canonical main process. If it exits, the @@ -190,7 +190,7 @@ To avoid passing `--policy` every time, set a default policy with an environment ```shell export OPENSHELL_SANDBOX_POLICY=./my-policy.yaml -openshell sandbox create -- claude +openshell sandbox create --from registry.example.com/your-org/claude-agent:latest -- claude ``` The CLI uses the policy from `OPENSHELL_SANDBOX_POLICY` whenever `--policy` is not explicitly provided. diff --git a/e2e/configs/gateway/docker.toml b/e2e/configs/gateway/docker.toml index 3e872885e..e186004cd 100644 --- a/e2e/configs/gateway/docker.toml +++ b/e2e/configs/gateway/docker.toml @@ -20,7 +20,7 @@ kid_path = ".cache/openshell-e2e/gateway-jwt/kid" gateway_id = "openshell-e2e" [openshell.drivers.docker] -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" image_pull_policy = "if_not_present" sandbox_label = "openshell-e2e" sandbox_runtime_image = "localhost/openshell/sandbox:e2e-vm" diff --git a/e2e/configs/gateway/podman.toml b/e2e/configs/gateway/podman.toml index a53708c61..332accc98 100644 --- a/e2e/configs/gateway/podman.toml +++ b/e2e/configs/gateway/podman.toml @@ -20,7 +20,7 @@ kid_path = ".cache/openshell-e2e/gateway-jwt/kid" gateway_id = "openshell-e2e" [openshell.drivers.podman] -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" image_pull_policy = "if_not_present" health_check_interval_secs = 10 network_name = "openshell-e2e" diff --git a/e2e/configs/gateway/schema-v2-compute-boundary-comparison.json b/e2e/configs/gateway/schema-v2-compute-boundary-comparison.json index 783bb0093..b29829a78 100644 --- a/e2e/configs/gateway/schema-v2-compute-boundary-comparison.json +++ b/e2e/configs/gateway/schema-v2-compute-boundary-comparison.json @@ -53,11 +53,11 @@ "supervisor_base_image_digest": "sha256:7c8cb692ae09657cbc4a3f3cbd0e8d5a2690ba38386aaaf252dbb060bf5eb2e6", "supervisor_base_runtime_image": "docker.io/library/alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce", "supervisor_package_manifest_sha256": "6966057ffaf1ef0d4617d413dad53646bcd049a4b08f572796392fb0ffbb01b9", - "sandbox_image_request": "ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", + "sandbox_image_request": "nvcr.io/nvidia/base/ubuntu@sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", "sandbox_image_id": "65fa5d3d598a07d385ddbea41bf593be15af306337b76255b40705287cebcbbf", - "sandbox_image_digest": "sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", - "sandbox_runtime_image": "ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", - "sandbox_client_image_alias": "ghcr.io/nvidia/openshell-community/sandboxes/base:latest", + "sandbox_image_digest": "sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", + "sandbox_runtime_image": "nvcr.io/nvidia/base/ubuntu@sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", + "sandbox_client_image_alias": "nvcr.io/nvidia/base/ubuntu:24.04", "sandbox_client_image_alias_id": "65fa5d3d598a07d385ddbea41bf593be15af306337b76255b40705287cebcbbf", "gateway_sha256_before_execution": "264cf3d809bd1d54633bce9251ec1a5540b567b8150640091df85bdfbe61797a", "cli_sha256_before_execution": "3ad0ec143bf6850af780bf643c303242956d6ef1066d4a9372bc62fef33ada20", @@ -121,11 +121,11 @@ "supervisor_base_image_digest": "sha256:7c8cb692ae09657cbc4a3f3cbd0e8d5a2690ba38386aaaf252dbb060bf5eb2e6", "supervisor_base_runtime_image": "docker.io/library/alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce", "supervisor_package_manifest_sha256": "6966057ffaf1ef0d4617d413dad53646bcd049a4b08f572796392fb0ffbb01b9", - "sandbox_image_request": "ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", + "sandbox_image_request": "nvcr.io/nvidia/base/ubuntu@sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", "sandbox_image_id": "65fa5d3d598a07d385ddbea41bf593be15af306337b76255b40705287cebcbbf", - "sandbox_image_digest": "sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", - "sandbox_runtime_image": "ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", - "sandbox_client_image_alias": "ghcr.io/nvidia/openshell-community/sandboxes/base:latest", + "sandbox_image_digest": "sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", + "sandbox_runtime_image": "nvcr.io/nvidia/base/ubuntu@sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", + "sandbox_client_image_alias": "nvcr.io/nvidia/base/ubuntu:24.04", "sandbox_client_image_alias_id": "65fa5d3d598a07d385ddbea41bf593be15af306337b76255b40705287cebcbbf", "gateway_sha256_before_execution": "2bd42b145f0438f48a579b010537f501e036035b0e22a4ff70e37db733a6e6ff", "cli_sha256_before_execution": "82827d9068f3e9c75681a804f8fb48274ecf8acb179a46f179fb9fbfe828f69a", @@ -196,11 +196,11 @@ "supervisor_base_image_digest": "sha256:7c8cb692ae09657cbc4a3f3cbd0e8d5a2690ba38386aaaf252dbb060bf5eb2e6", "supervisor_base_runtime_image": "docker.io/library/alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce", "supervisor_package_manifest_sha256": "6966057ffaf1ef0d4617d413dad53646bcd049a4b08f572796392fb0ffbb01b9", - "sandbox_image_request": "ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", + "sandbox_image_request": "nvcr.io/nvidia/base/ubuntu@sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", "sandbox_image_id": "65fa5d3d598a07d385ddbea41bf593be15af306337b76255b40705287cebcbbf", - "sandbox_image_digest": "sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", - "sandbox_runtime_image": "ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", - "sandbox_client_image_alias": "ghcr.io/nvidia/openshell-community/sandboxes/base:latest", + "sandbox_image_digest": "sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", + "sandbox_runtime_image": "nvcr.io/nvidia/base/ubuntu@sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", + "sandbox_client_image_alias": "nvcr.io/nvidia/base/ubuntu:24.04", "sandbox_client_image_alias_id": "65fa5d3d598a07d385ddbea41bf593be15af306337b76255b40705287cebcbbf", "gateway_sha256_before_execution": "5cc2f700d93dde5dd09060d9c9190ae44a99440b1399530a2b23941ec4e88f85", "cli_sha256_before_execution": "3ad0ec143bf6850af780bf643c303242956d6ef1066d4a9372bc62fef33ada20", @@ -218,7 +218,7 @@ "external_driver_environment": { "OPENSHELL_COMPUTE_DRIVER_SOCKET": "/tmp/openshell-e2e-podman.H8oMaO/compute-driver.sock", "OPENSHELL_PODMAN_SOCKET": "/tmp/openshell-e2e-podman.H8oMaO/podman/podman.sock", - "OPENSHELL_SANDBOX_IMAGE": "ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", + "OPENSHELL_SANDBOX_IMAGE": "nvcr.io/nvidia/base/ubuntu@sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", "OPENSHELL_SANDBOX_IMAGE_PULL_POLICY": "missing", "OPENSHELL_HEALTH_CHECK_INTERVAL_SECS": 10, "OPENSHELL_GRPC_ENDPOINT": "https://host.containers.internal:50871", @@ -290,11 +290,11 @@ "supervisor_base_image_digest": "sha256:7c8cb692ae09657cbc4a3f3cbd0e8d5a2690ba38386aaaf252dbb060bf5eb2e6", "supervisor_base_runtime_image": "docker.io/library/alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce", "supervisor_package_manifest_sha256": "6966057ffaf1ef0d4617d413dad53646bcd049a4b08f572796392fb0ffbb01b9", - "sandbox_image_request": "ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", + "sandbox_image_request": "nvcr.io/nvidia/base/ubuntu@sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", "sandbox_image_id": "65fa5d3d598a07d385ddbea41bf593be15af306337b76255b40705287cebcbbf", - "sandbox_image_digest": "sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", - "sandbox_runtime_image": "ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", - "sandbox_client_image_alias": "ghcr.io/nvidia/openshell-community/sandboxes/base:latest", + "sandbox_image_digest": "sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", + "sandbox_runtime_image": "nvcr.io/nvidia/base/ubuntu@sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", + "sandbox_client_image_alias": "nvcr.io/nvidia/base/ubuntu:24.04", "sandbox_client_image_alias_id": "65fa5d3d598a07d385ddbea41bf593be15af306337b76255b40705287cebcbbf", "gateway_sha256_before_execution": "fdefc047c1b675c311b1796db9f1b1a944456fc34b76547efc0352c6a75a7707", "cli_sha256_before_execution": "82827d9068f3e9c75681a804f8fb48274ecf8acb179a46f179fb9fbfe828f69a", @@ -312,7 +312,7 @@ "external_driver_environment": { "OPENSHELL_COMPUTE_DRIVER_SOCKET": "/tmp/openshell-e2e-podman.M8xsgQ/compute-driver.sock", "OPENSHELL_PODMAN_SOCKET": "/tmp/openshell-e2e-podman.M8xsgQ/podman/podman.sock", - "OPENSHELL_SANDBOX_IMAGE": "ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:c2a43bb0d765774e2790b3babfb20997bb2eac7b4bf4c6d7d8661e99817bf904", + "OPENSHELL_SANDBOX_IMAGE": "nvcr.io/nvidia/base/ubuntu@sha256:c280ee89f8bfcbdaba6179ad4347f60c509e841cbe63eb93002f01bf70e0819c", "OPENSHELL_SANDBOX_IMAGE_PULL_POLICY": "if_not_present", "OPENSHELL_HEALTH_CHECK_INTERVAL_SECS": 10, "OPENSHELL_GRPC_ENDPOINT": "https://host.containers.internal:32901", diff --git a/e2e/docker/Dockerfile.external-kubernetes-gateway b/e2e/docker/Dockerfile.external-kubernetes-gateway index 20738a512..3b860dacc 100644 --- a/e2e/docker/Dockerfile.external-kubernetes-gateway +++ b/e2e/docker/Dockerfile.external-kubernetes-gateway @@ -17,7 +17,7 @@ ENV OPENSHELL_COMPUTE_DRIVER=kubernetes \ OPENSHELL_GATEWAY_ID=openshell \ OPENSHELL_SANDBOX_NAMESPACE=openshell \ OPENSHELL_K8S_SANDBOX_SERVICE_ACCOUNT=openshell-sandbox \ - OPENSHELL_SANDBOX_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:latest \ + OPENSHELL_SANDBOX_IMAGE=nvcr.io/nvidia/base/ubuntu:24.04 \ OPENSHELL_SANDBOX_IMAGE_PULL_POLICY=if_not_present \ OPENSHELL_GRPC_ENDPOINT=http://openshell.openshell.svc.cluster.local:8080 \ OPENSHELL_SUPERVISOR_IMAGE=${SUPERVISOR_IMAGE} \ diff --git a/e2e/gpu/images/cuda-basic/Dockerfile b/e2e/gpu/images/cuda-basic/Dockerfile index a7dde7422..972103888 100644 --- a/e2e/gpu/images/cuda-basic/Dockerfile +++ b/e2e/gpu/images/cuda-basic/Dockerfile @@ -4,7 +4,7 @@ # SPDX-License-Identifier: Apache-2.0 ARG CUDA_BUILD_IMAGE=nvcr.io/nvidia/cuda:12.8.1-base-ubuntu22.04 -ARG OPENSHELL_SANDBOX_BASE_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:latest +ARG OPENSHELL_SANDBOX_BASE_IMAGE=nvcr.io/nvidia/base/ubuntu:24.04 FROM ${CUDA_BUILD_IMAGE} AS builder @@ -68,5 +68,5 @@ RUN chmod 0755 /usr/local/bin/openshell-gpu-workload \ /usr/local/lib/openshell-gpu-workload/deviceQuery \ /usr/local/lib/openshell-gpu-workload/vectorAdd -USER sandbox +USER 1000:1000 ENTRYPOINT ["/usr/local/bin/openshell-gpu-workload"] diff --git a/e2e/gpu/images/cuda-basic/README.md b/e2e/gpu/images/cuda-basic/README.md index deb0b5c5a..096d51302 100644 --- a/e2e/gpu/images/cuda-basic/README.md +++ b/e2e/gpu/images/cuda-basic/README.md @@ -12,7 +12,7 @@ runtime workload. It is a single image that runs two validation steps: The image builds the samples from `NVIDIA/cuda-samples` tag `v12.8` with a CUDA 12.8 builder image, then copies only the compiled binaries into the OpenShell -community base final image. +default workload final image. The workload prints `OPENSHELL_GPU_WORKLOAD_SUCCESS` only after both samples pass. On failure it prints `OPENSHELL_GPU_WORKLOAD_FAILURE` and exits non-zero. diff --git a/e2e/gpu/images/smoke-fail/Dockerfile b/e2e/gpu/images/smoke-fail/Dockerfile index f74aa3c5e..9e288d886 100644 --- a/e2e/gpu/images/smoke-fail/Dockerfile +++ b/e2e/gpu/images/smoke-fail/Dockerfile @@ -3,7 +3,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -ARG OPENSHELL_SANDBOX_BASE_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:latest +ARG OPENSHELL_SANDBOX_BASE_IMAGE=nvcr.io/nvidia/base/ubuntu:24.04 FROM ${OPENSHELL_SANDBOX_BASE_IMAGE} @@ -11,5 +11,5 @@ USER root COPY workload.sh /usr/local/bin/openshell-gpu-workload RUN chmod 0755 /usr/local/bin/openshell-gpu-workload -USER sandbox +USER 1000:1000 ENTRYPOINT ["/usr/local/bin/openshell-gpu-workload"] diff --git a/e2e/gpu/images/smoke-pass/Dockerfile b/e2e/gpu/images/smoke-pass/Dockerfile index f74aa3c5e..9e288d886 100644 --- a/e2e/gpu/images/smoke-pass/Dockerfile +++ b/e2e/gpu/images/smoke-pass/Dockerfile @@ -3,7 +3,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -ARG OPENSHELL_SANDBOX_BASE_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:latest +ARG OPENSHELL_SANDBOX_BASE_IMAGE=nvcr.io/nvidia/base/ubuntu:24.04 FROM ${OPENSHELL_SANDBOX_BASE_IMAGE} @@ -11,5 +11,5 @@ USER root COPY workload.sh /usr/local/bin/openshell-gpu-workload RUN chmod 0755 /usr/local/bin/openshell-gpu-workload -USER sandbox +USER 1000:1000 ENTRYPOINT ["/usr/local/bin/openshell-gpu-workload"] diff --git a/e2e/parity/kubernetes-options.sh b/e2e/parity/kubernetes-options.sh index 658e751c3..3ddca0355 100644 --- a/e2e/parity/kubernetes-options.sh +++ b/e2e/parity/kubernetes-options.sh @@ -22,7 +22,7 @@ KUBE_CONTEXT="${OPENSHELL_PARITY_KUBE_CONTEXT:-}" HOST_GATEWAY_IP="${OPENSHELL_PARITY_HOST_GATEWAY_IP:-}" RUN_ID="${OPENSHELL_PARITY_RUN_ID:-$(date +%s)-$$}" OUT="${OPENSHELL_PARITY_OUTPUT_DIR:-${ROOT}/target/parity/step8-kubernetes-${CANDIDATE_SHA:0:8}}" -SANDBOX_IMAGE="${OPENSHELL_PARITY_KUBERNETES_SANDBOX_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/base:latest}" +SANDBOX_IMAGE="${OPENSHELL_PARITY_KUBERNETES_SANDBOX_IMAGE:-nvcr.io/nvidia/base/ubuntu:24.04}" SUPERVISOR_IMAGE="${OPENSHELL_PARITY_KUBERNETES_SUPERVISOR_IMAGE:-ghcr.io/nvidia/openshell/supervisor:latest}" RUNTIME_CLASS="openshell-parity-runc-${RUN_ID}" diff --git a/e2e/parity/podman-options.sh b/e2e/parity/podman-options.sh index f1894b587..ae24ec036 100755 --- a/e2e/parity/podman-options.sh +++ b/e2e/parity/podman-options.sh @@ -10,7 +10,7 @@ set -euo pipefail CLI="${OPENSHELL_BIN:?OPENSHELL_BIN is required}" RESULT="${OPENSHELL_PARITY_ORACLE_RESULT:?OPENSHELL_PARITY_ORACLE_RESULT is required}" VARIANT="${OPENSHELL_PARITY_VARIANT:?OPENSHELL_PARITY_VARIANT is required}" -IMAGE="${OPENSHELL_E2E_PODMAN_SANDBOX_IMAGE:-${OPENSHELL_SANDBOX_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/base:latest}}" +IMAGE="${OPENSHELL_E2E_PODMAN_SANDBOX_IMAGE:-${OPENSHELL_SANDBOX_IMAGE:-nvcr.io/nvidia/base/ubuntu:24.04}}" GATEWAY_LOG="${OPENSHELL_E2E_GATEWAY_LOG:?OPENSHELL_E2E_GATEWAY_LOG is required}" NAME="po-${VARIANT:0:1}-${RANDOM}" WORKDIR="${TMPDIR:-/tmp}/openshell-parity-options-${NAME}" diff --git a/e2e/parity/run.sh b/e2e/parity/run.sh index e6217df0e..87da1c939 100755 --- a/e2e/parity/run.sh +++ b/e2e/parity/run.sh @@ -21,10 +21,9 @@ PODMAN_OPTIONS_ORACLE="${OPENSHELL_PARITY_PODMAN_OPTIONS_ORACLE:-${ROOT}/e2e/par CONFORMANCE_TRACE_WRAPPER="${ROOT}/e2e/parity/trace-conformance.sh" RESULTS_VERIFIER="${ROOT}/e2e/parity/verify-results.py" PODMAN_BIN="${OPENSHELL_PARITY_PODMAN_BIN:-podman}" -DEFAULT_SANDBOX_IMAGE="ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +DEFAULT_SANDBOX_IMAGE="nvcr.io/nvidia/base/ubuntu:24.04" SANDBOX_IMAGE_REQUEST="${OPENSHELL_E2E_PODMAN_SANDBOX_IMAGE:-${DEFAULT_SANDBOX_IMAGE}}" PARITY_SANDBOX_RUNTIME_IMAGE="" -PARITY_SANDBOX_COMMUNITY_REGISTRY="" PARITY_SUPERVISOR_BASE_IMAGE="" PARITY_SUPERVISOR_BASE_RUNTIME_IMAGE="" TEMP_WORKTREE="" @@ -404,11 +403,6 @@ resolve_parity_sandbox_image() { echo "ERROR: could not resolve one immutable parity sandbox image from ${SANDBOX_IMAGE_REQUEST}." >&2 exit 2 fi - PARITY_SANDBOX_COMMUNITY_REGISTRY="${repository%/base}" - if [ "${PARITY_SANDBOX_COMMUNITY_REGISTRY}" = "${repository}" ]; then - echo "ERROR: parity sandbox repository must end in /base for the conformance alias: ${repository}." >&2 - exit 2 - fi echo "Using one immutable parity sandbox image for both variants: ${PARITY_SANDBOX_RUNTIME_IMAGE} (ID ${image_id})" } @@ -551,7 +545,6 @@ run_variant() { -u OPENSHELL_SANDBOX_PROXY_AUTH_FILE -u OPENSHELL_SANDBOX_PROXY_AUTH_ALLOW_INSECURE \ -u OPENSHELL_SANDBOX_PROXY_CONNECT_BY_HOSTNAME -u OPENSHELL_SANDBOX_PROXY_CA_BUNDLE \ -u OPENSHELL_OTLP_ENDPOINT -u OPENSHELL_GATEWAY_NAME -u OPENSHELL_COMPUTE_DRIVER_BIND \ - -u OPENSHELL_COMMUNITY_REGISTRY \ OPENSHELL_PARITY_VARIANT="${variant}" \ OPENSHELL_E2E_CONFIG_SCHEMA_VERSION="${schema}" \ OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER="$([ "${SCENARIO}" = external-driver ] && printf 1 || printf 0)" \ @@ -561,7 +554,6 @@ run_variant() { OPENSHELL_E2E_FORCE_TEMP_PODMAN_SERVICE=1 \ OPENSHELL_E2E_REQUIRE_DIGEST_PINNED_SANDBOX_IMAGE=1 \ OPENSHELL_E2E_PODMAN_SANDBOX_IMAGE="${PARITY_SANDBOX_RUNTIME_IMAGE}" \ - OPENSHELL_COMMUNITY_REGISTRY="${PARITY_SANDBOX_COMMUNITY_REGISTRY}" \ OPENSHELL_E2E_SUPERVISOR_BASE_IMAGE="${PARITY_SUPERVISOR_BASE_IMAGE}" \ OPENSHELL_E2E_SUPERVISOR_BASE_RUNTIME_IMAGE="${PARITY_SUPERVISOR_BASE_RUNTIME_IMAGE}" \ OPENSHELL_E2E_EXPECTED_GATEWAY_SHA256="${gateway_digest}" \ diff --git a/e2e/parity/test.sh b/e2e/parity/test.sh index e982ccefa..ad4ad6541 100755 --- a/e2e/parity/test.sh +++ b/e2e/parity/test.sh @@ -138,10 +138,9 @@ for variable in \ OPENSHELL_OTLP_ENDPOINT OPENSHELL_GATEWAY_NAME OPENSHELL_COMPUTE_DRIVER_BIND; do [ -z "${!variable:-}" ] || exit 23 done -expected_sandbox="ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:$(printf '%064d' 0)" +expected_sandbox="nvcr.io/nvidia/base/ubuntu@sha256:$(printf '%064d' 0)" [ "${OPENSHELL_E2E_REQUIRE_DIGEST_PINNED_SANDBOX_IMAGE:-0}" = 1 ] || exit 24 [ "${OPENSHELL_E2E_PODMAN_SANDBOX_IMAGE:-}" = "${expected_sandbox}" ] || exit 25 -[ "${OPENSHELL_COMMUNITY_REGISTRY:-}" = "ghcr.io/nvidia/openshell-community/sandboxes" ] || exit 28 expected_base="docker.io/library/debian@sha256:$(printf '%064d' 0)" [ "${OPENSHELL_E2E_SUPERVISOR_BASE_IMAGE:-}" = "${OPENSHELL_PARITY_TEST_SUPERVISOR_BASE}" ] || exit 26 [ "${OPENSHELL_E2E_SUPERVISOR_BASE_RUNTIME_IMAGE:-}" = "${expected_base}" ] || exit 27 @@ -159,7 +158,7 @@ schema = int(os.environ["OPENSHELL_E2E_CONFIG_SCHEMA_VERSION"]) external = os.environ.get("OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER") == "1" zero = "0" * 64 image_digest = f"sha256:{zero}" -sandbox_runtime = f"ghcr.io/nvidia/openshell-community/sandboxes/base@{image_digest}" +sandbox_runtime = f"nvcr.io/nvidia/base/ubuntu@{image_digest}" sandbox_boundary = "localhost/openshell/sandbox:dev" supervisor_runtime = f"localhost/openshell/supervisor@{image_digest}" base_runtime = f"docker.io/library/debian@{image_digest}" @@ -218,7 +217,7 @@ launch = { "sandbox_image_digest": image_digest, "sandbox_runtime_image": sandbox_runtime, "sandbox_boundary_image": sandbox_boundary, - "sandbox_client_image_alias": "ghcr.io/nvidia/openshell-community/sandboxes/base:latest", + "sandbox_client_image_alias": "nvcr.io/nvidia/base/ubuntu:24.04", "sandbox_client_image_alias_id": zero, "gateway_sha256_before_execution": os.environ[ "OPENSHELL_E2E_EXPECTED_GATEWAY_SHA256" @@ -292,7 +291,7 @@ done printf '%s %064d sha256:%064d %s %s %s %s\n' \ "${expected_sandbox}" 0 0 "${expected_base}" \ "localhost/openshell/supervisor@sha256:$(printf '%064d' 0)" \ - "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" \ + "nvcr.io/nvidia/base/ubuntu:24.04" \ "${package_hash}" >&2 if [ "${OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER:-0}" = 1 ]; then printf 'fixture external driver log\n' >"${OPENSHELL_PARITY_EXTERNAL_DRIVER_LOG_CAPTURE}" @@ -422,7 +421,6 @@ OPENSHELL_SANDBOX_PROXY_CA_BUNDLE=/tmp/untrusted-proxy-ca \ OPENSHELL_OTLP_ENDPOINT=http://untrusted.invalid:4317 \ OPENSHELL_GATEWAY_NAME=untrusted \ OPENSHELL_COMPUTE_DRIVER_BIND=192.0.2.2:50061 \ -OPENSHELL_COMMUNITY_REGISTRY=untrusted.invalid/community \ run_harness assert_contains "${WORKDIR}/calls" "baseline|1|${WORKDIR}/results/artifacts/baseline/gateway|${WORKDIR}/results/artifacts/baseline/cli|${WORKDIR}/results/artifacts/baseline/conformance" assert_contains "${WORKDIR}/calls" "candidate|2|${WORKDIR}/results/artifacts/candidate/gateway|${WORKDIR}/results/artifacts/candidate/cli|${WORKDIR}/results/artifacts/candidate/conformance" @@ -439,7 +437,7 @@ assert_contains "${WORKDIR}/results/semantic-verification.json" '"accepted": tru assert_not_contains "${WORKDIR}/results/baseline.json" '"scenarios"' assert_contains "${WORKDIR}/results/baseline.log" '"scenarios"' assert_contains "${WORKDIR}/results/baseline.conformance.json" '"passed":true' -assert_contains "${WORKDIR}/podman-calls" 'pull ghcr.io/nvidia/openshell-community/sandboxes/base:latest' +assert_contains "${WORKDIR}/podman-calls" 'pull nvcr.io/nvidia/base/ubuntu:24.04' assert_contains "${WORKDIR}/podman-calls" "pull ${TEST_SUPERVISOR_BASE}" assert_contains "${WORKDIR}/podman-calls" 'unshare rm -rf -- ' assert_contains "${WORKDIR}/podman-calls" 'openshell-parity-run.' diff --git a/e2e/parity/verify-results.py b/e2e/parity/verify-results.py index d785c1f3e..4b9bc1e7b 100644 --- a/e2e/parity/verify-results.py +++ b/e2e/parity/verify-results.py @@ -56,6 +56,16 @@ def require(condition: bool, message: str) -> None: raise ValueError(message) +def image_repository(reference: str) -> str: + """Return an OCI image repository without a tag or digest.""" + repository = reference.split("@", 1)[0] + last_slash = repository.rfind("/") + last_colon = repository.rfind(":") + if last_colon > last_slash: + repository = repository[:last_colon] + return repository + + def verify_conformance_report(path: Path) -> None: report = load_json(path) require(report.get("passed") is True, f"{path}: conformance report did not pass") @@ -330,7 +340,8 @@ def verify_variant( sandbox_alias = launch.get("sandbox_client_image_alias") require( isinstance(sandbox_alias, str) - and sandbox_alias == sandbox_runtime.rsplit("@", 1)[0] + ":latest" + and "@" not in sandbox_alias + and image_repository(sandbox_alias) == image_repository(sandbox_runtime) and launch.get("sandbox_client_image_alias_id") == sandbox_id, f"{launch_path}: sandbox client alias is not bound to the pinned image", ) diff --git a/e2e/python/Dockerfile.workload b/e2e/python/Dockerfile.workload new file mode 100644 index 000000000..0da6a88f6 --- /dev/null +++ b/e2e/python/Dockerfile.workload @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +ARG WORKLOAD_BASE=nvcr.io/nvidia/base/ubuntu:24.04 +FROM ghcr.io/astral-sh/uv:0.12.17@sha256:10787c682e4184e4f290de1171fd4703dc63de99221f10fe1c99002ce7fa9acc AS uv +FROM ${WORKLOAD_BASE} + +COPY --from=uv /uv /uvx /usr/local/bin/ +# Supplied from .python-version by the build task for cloudpickle compatibility. +ARG PYTHON_VERSION +# curl and openssl also support the Rust Docker tests sharing this fixture. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash ca-certificates curl git openssl python3 python3-pip python3-venv python-is-python3 \ + && rm -rf /var/lib/apt/lists/* \ + && if getent passwd ubuntu >/dev/null; then userdel ubuntu; fi \ + && if getent group ubuntu >/dev/null; then groupdel ubuntu; fi \ + && groupadd --gid 1000 sandbox \ + && useradd --uid 1000 --gid sandbox --create-home --shell /bin/bash sandbox \ + && UV_PYTHON_INSTALL_DIR=/sandbox/.uv/python uv python install "${PYTHON_VERSION}" \ + && UV_PYTHON_INSTALL_DIR=/sandbox/.uv/python uv venv --python "${PYTHON_VERSION}" --seed /sandbox/.venv \ + && uv pip install --python /sandbox/.venv/bin/python cloudpickle==3.1.2 \ + && chown -R sandbox:sandbox /sandbox \ + && uv cache clean + +ENV VIRTUAL_ENV=/sandbox/.venv +ENV PATH="/sandbox/.venv/bin:${PATH}" +WORKDIR /sandbox +USER sandbox:sandbox diff --git a/e2e/python/conftest.py b/e2e/python/conftest.py index ed8775017..3c2bea236 100644 --- a/e2e/python/conftest.py +++ b/e2e/python/conftest.py @@ -93,8 +93,7 @@ def sandbox(cluster_name: str | None) -> Callable[..., Sandbox]: cluster=cluster_name, spec=spec, delete_on_exit=delete_on_exit, - # The sandbox image is large (Python, Node.js, coding agents) so the - # first pod in the cluster may need extra time for the image pull. + # Allow time to pull an explicitly supplied workload fixture. ready_timeout_seconds=300.0, ) diff --git a/e2e/python/test_sandbox_api.py b/e2e/python/test_sandbox_api.py index e28780645..87b9e8c49 100644 --- a/e2e/python/test_sandbox_api.py +++ b/e2e/python/test_sandbox_api.py @@ -206,6 +206,50 @@ def test_sandbox_interactive_exec_honors_tty( assert b"TTT" in stdout + stderr +def test_interactive_exec_drains_output_after_request_eof( + sandbox: Callable[..., Sandbox], + sandbox_client: SandboxClient, +) -> None: + with sandbox(delete_on_exit=True) as sb: + + def requests(): + yield openshell_pb2.ExecSandboxInput( + start=openshell_pb2.ExecSandboxRequest( + sandbox=sb.sandbox.name, + workspace_scope=datamodel_pb2.WorkspaceSelector(workspace="default"), + command=[ + "/bin/sh", + "-c", + "input=$(cat); sleep 0.2; " + "printf '%s' \"$input\"; printf 'drained-stderr' >&2; exit 7", + ], + tty=False, + execution_timeout=duration_pb2.Duration(seconds=20), + ) + ) + yield openshell_pb2.ExecSandboxInput(stdin=b"drained-stdout") + # End requests before the command emits output. The receive + # direction must survive long enough to drain both output streams. + + stdout = bytearray() + stderr = bytearray() + exit_codes: list[int] = [] + for event in sandbox_client._stub.ExecSandboxInteractive( + requests(), timeout=30 + ): + assert not exit_codes, "received another event after terminal exit" + payload = event.WhichOneof("payload") + if payload == "stdout": + stdout.extend(event.stdout.data) + elif payload == "stderr": + stderr.extend(event.stderr.data) + elif payload == "exit": + exit_codes.append(event.exit.exit_code) + assert stdout == b"drained-stdout" + assert stderr == b"drained-stderr" + assert exit_codes == [7] + + def test_list_scoped_and_for_all_workspaces( sandbox_client: SandboxClient, workspace_client: WorkspaceClient, diff --git a/e2e/rust/e2e-docker.sh b/e2e/rust/e2e-docker.sh index ba97b85a5..b19477513 100755 --- a/e2e/rust/e2e-docker.sh +++ b/e2e/rust/e2e-docker.sh @@ -44,5 +44,9 @@ if [ "${1:-}" = "${RUN_WITH_GATEWAY_COMMAND}" ]; then exit 0 fi +if [ -n "${E2E_FEATURES}" ]; then + CONTAINER_ENGINE=docker bash "${ROOT}/tasks/scripts/e2e-build-workload.sh" +fi + exec "${ROOT}/e2e/with-docker-gateway.sh" \ bash "${BASH_SOURCE[0]}" "${RUN_WITH_GATEWAY_COMMAND}" diff --git a/e2e/rust/src/harness/container.rs b/e2e/rust/src/harness/container.rs index 5e23a70ff..5ef7ca926 100644 --- a/e2e/rust/src/harness/container.rs +++ b/e2e/rust/src/harness/container.rs @@ -15,8 +15,7 @@ use std::time::Duration; use tokio::time::{interval, timeout}; use super::port::find_free_port; - -const DEFAULT_TEST_SERVER_IMAGE: &str = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest"; +use super::sandbox::E2E_WORKLOAD_IMAGE; #[must_use] pub fn e2e_driver() -> Option { @@ -197,7 +196,7 @@ impl ContainerHttpServer { ]); } args.extend([ - DEFAULT_TEST_SERVER_IMAGE.to_string(), + E2E_WORKLOAD_IMAGE.to_string(), "-c".to_string(), script.to_string(), ]); @@ -325,7 +324,7 @@ impl HostSupportContainer { "python3", "-p", &format!("{port}:{container_port}"), - DEFAULT_TEST_SERVER_IMAGE, + E2E_WORKLOAD_IMAGE, "-c", script, ]) @@ -380,7 +379,7 @@ impl HostSupportContainer { .map(|capability| format!("--cap-add={capability}")), ); args.extend([ - DEFAULT_TEST_SERVER_IMAGE.to_string(), + E2E_WORKLOAD_IMAGE.to_string(), "-c".to_string(), script.to_string(), ]); @@ -493,7 +492,7 @@ impl SupportContainer { .map(|capability| format!("--cap-add={capability}")), ); args.extend([ - DEFAULT_TEST_SERVER_IMAGE.to_string(), + E2E_WORKLOAD_IMAGE.to_string(), "-c".to_string(), script.to_string(), ]); diff --git a/e2e/rust/src/harness/sandbox.rs b/e2e/rust/src/harness/sandbox.rs index e0a1f3589..fb7aff9d9 100644 --- a/e2e/rust/src/harness/sandbox.rs +++ b/e2e/rust/src/harness/sandbox.rs @@ -17,6 +17,18 @@ use tokio::time::timeout; use super::binary::openshell_cmd; use super::output::{extract_field, strip_ansi}; +/// Tool-capable workload image used by the E2E harness. +/// +/// Product defaults remain on the minimal NVIDIA Ubuntu image. Tests that +/// explicitly pass `--from` or `--template` retain their requested workload. +/// Docker setup builds this Noble-based fixture before running the tests. +#[cfg(feature = "e2e-docker")] +pub const E2E_WORKLOAD_IMAGE: &str = "openshell/e2e-python:dev"; + +/// Preserve the existing pullable fixture for non-Docker E2E lanes. +#[cfg(not(feature = "e2e-docker"))] +pub const E2E_WORKLOAD_IMAGE: &str = "ghcr.io/astral-sh/uv:0.12.17-python3.12-trixie-slim@sha256:9a59bb7206905ccaae4f7dab222fbac47c125a21e5fc16f43f427cd6c940ade3"; + /// Extract the sandbox name from CLI create output. /// /// The CLI prints `Created sandbox: ` (current format). Falls back to @@ -26,10 +38,9 @@ fn extract_sandbox_name(output: &str) -> Option { } /// Default timeout for waiting for a sandbox to become ready. -/// In VM mode, the overlayfs snapshotter re-extracts all image layers -/// from the content store on every boot (~250s for the 1GB sandbox -/// base image), so 600s accommodates extraction + workspace-init + pod -/// startup. +/// In VM mode, the overlayfs snapshotter re-extracts image layers from the +/// content store on every boot, so 600s accommodates cold image preparation, +/// workspace initialization, and sandbox startup. const SANDBOX_READY_TIMEOUT: Duration = Duration::from_secs(600); static NEXT_SANDBOX_NAME: AtomicU64 = AtomicU64::new(1); @@ -39,6 +50,21 @@ fn has_explicit_sandbox_name(args: &[&str]) -> bool { .any(|arg| *arg == "--name" || arg.starts_with("--name=")) } +fn has_explicit_sandbox_workload(args: &[&str]) -> bool { + args.iter().take_while(|arg| **arg != "--").any(|arg| { + *arg == "--from" + || arg.starts_with("--from=") + || *arg == "--template" + || arg.starts_with("--template=") + }) +} + +fn add_test_image_if_missing(command: &mut tokio::process::Command, args: &[&str]) { + if !has_explicit_sandbox_workload(args) { + command.arg("--from").arg(E2E_WORKLOAD_IMAGE); + } +} + fn add_unique_name_if_missing(command: &mut tokio::process::Command, args: &[&str]) { if !has_explicit_sandbox_name(args) { command.arg("--name").arg(format!( @@ -96,6 +122,18 @@ impl SandboxGuard { /// Returns an error if the CLI exits with a non-zero status or the sandbox /// name cannot be parsed from the output. pub async fn create(args: &[&str]) -> Result { + Self::create_inner(args, true).await + } + + /// Create a sandbox using the gateway's configured default image. + /// + /// Most E2E tests use [`Self::create`], which supplies the tool-capable E2E + /// image. This variant is reserved for coverage of the product default. + pub async fn create_with_gateway_default(args: &[&str]) -> Result { + Self::create_inner(args, false).await + } + + async fn create_inner(args: &[&str], use_test_image: bool) -> Result { let separator = args.iter().position(|arg| *arg == "--"); let (create_args, command) = separator.map_or((args, &[][..]), |index| { (&args[..index], &args[index + 1..]) @@ -111,6 +149,9 @@ impl SandboxGuard { let mut cmd = openshell_cmd(); cmd.arg("sandbox").arg("create").arg("--detach"); add_unique_name_if_missing(&mut cmd, create_args); + if use_test_image { + add_test_image_if_missing(&mut cmd, create_args); + } for arg in create_args { cmd.arg(arg); } @@ -187,6 +228,7 @@ impl SandboxGuard { let mut cmd = openshell_cmd(); cmd.arg("sandbox").arg("create").arg("--detach"); add_unique_name_if_missing(&mut cmd, &[]); + add_test_image_if_missing(&mut cmd, &[]); cmd.arg("--") .args(command) .stdout(Stdio::piped()) @@ -235,6 +277,7 @@ impl SandboxGuard { let mut create_cmd = openshell_cmd(); create_cmd.arg("sandbox").arg("create").arg("--detach"); add_unique_name_if_missing(&mut create_cmd, create_args); + add_test_image_if_missing(&mut create_cmd, create_args); for arg in create_args { create_cmd.arg(arg); } @@ -374,6 +417,7 @@ impl SandboxGuard { let mut cmd = openshell_cmd(); cmd.arg("sandbox").arg("create").arg("--detach"); add_unique_name_if_missing(&mut cmd, &[]); + add_test_image_if_missing(&mut cmd, &[]); for (local, dest) in uploads { cmd.arg("--upload").arg(format!("{local}:{dest}")); } @@ -703,7 +747,7 @@ impl Drop for SandboxGuard { #[cfg(test)] mod tests { - use super::has_explicit_sandbox_name; + use super::{has_explicit_sandbox_name, has_explicit_sandbox_workload}; #[test] fn detects_explicit_sandbox_names() { @@ -711,4 +755,19 @@ mod tests { assert!(has_explicit_sandbox_name(&["--name=example"])); assert!(!has_explicit_sandbox_name(&["--policy", "policy.yaml"])); } + + #[test] + fn detects_explicit_sandbox_workloads() { + assert!(has_explicit_sandbox_workload(&["--from", "example:latest"])); + assert!(has_explicit_sandbox_workload(&["--from=example:latest"])); + assert!(has_explicit_sandbox_workload(&["--template", "example"])); + assert!(has_explicit_sandbox_workload(&["--template=example"])); + assert!(!has_explicit_sandbox_workload(&["--policy", "policy.yaml"])); + assert!(!has_explicit_sandbox_workload(&["--", "echo", "--from=x"])); + assert!(!has_explicit_sandbox_workload(&[ + "--", + "echo", + "--template=x" + ])); + } } diff --git a/e2e/rust/tests/community_image.rs b/e2e/rust/tests/community_image.rs deleted file mode 100644 index 2fab46385..000000000 --- a/e2e/rust/tests/community_image.rs +++ /dev/null @@ -1,61 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -#![cfg(feature = "e2e")] - -//! E2E test: pull and launch a community sandbox image from GHCR. -//! -//! This test verifies that: -//! 1. The `base` community sandbox image can be pulled from GHCR -//! 2. A sandbox can be created and run using the community image -//! 3. Basic command execution works inside the community sandbox -//! -//! Prerequisites: -//! - A running openshell gateway (`mise run gateway:docker`) -//! - Network access to ghcr.io/nvidia/openshell-community/sandboxes/ - -use openshell_e2e::harness::output::strip_ansi; -use openshell_e2e::harness::sandbox::SandboxGuard; - -/// Create a sandbox using the community `base` image and verify it works. -/// -/// The `--from base` argument should resolve to: -/// `ghcr.io/nvidia/openshell-community/sandboxes/base:latest` -#[tokio::test] -async fn sandbox_from_community_base_image() { - // Create a sandbox using the community "base" image. - // The CLI should expand "base" to the full GHCR path. - let mut guard = SandboxGuard::create(&["--from", "base", "--", "echo", "community-image-ok"]) - .await - .expect("sandbox create from community base image"); - - // Verify the command output contains our marker. - let clean_output = strip_ansi(&guard.create_output); - assert!( - clean_output.contains("community-image-ok"), - "expected 'community-image-ok' in sandbox output:\n{clean_output}" - ); - - guard.cleanup().await; -} - -/// Create a sandbox using the full GHCR image path explicitly. -/// -/// This tests that explicit image references work correctly. -#[tokio::test] -async fn sandbox_from_explicit_ghcr_image() { - let image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest"; - - let mut guard = SandboxGuard::create(&["--from", image, "--", "cat", "/etc/os-release"]) - .await - .expect("sandbox create from explicit GHCR image"); - - // The base image should have an /etc/os-release file. - let clean_output = strip_ansi(&guard.create_output); - assert!( - clean_output.contains("ID=") || clean_output.contains("NAME="), - "expected OS release info in sandbox output:\n{clean_output}" - ); - - guard.cleanup().await; -} diff --git a/e2e/rust/tests/default_image.rs b/e2e/rust/tests/default_image.rs new file mode 100644 index 000000000..6a1dbb3cd --- /dev/null +++ b/e2e/rust/tests/default_image.rs @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e")] + +//! E2E coverage for the default NVIDIA Ubuntu workload image. + +use openshell_e2e::harness::output::strip_ansi; +use openshell_e2e::harness::sandbox::SandboxGuard; + +#[tokio::test] +async fn sandbox_from_default_image() { + let mut guard = SandboxGuard::create_with_gateway_default(&["--", "cat", "/etc/os-release"]) + .await + .expect("sandbox create from default image"); + + let clean_output = strip_ansi(&guard.create_output); + assert!( + clean_output.contains("ID=ubuntu"), + "expected Ubuntu OS release info in sandbox output:\n{clean_output}" + ); + + guard.cleanup().await; +} + +#[tokio::test] +async fn sandbox_from_explicit_nvidia_ubuntu_image() { + let image = "nvcr.io/nvidia/base/ubuntu:24.04"; + let mut guard = SandboxGuard::create(&["--from", image, "--", "cat", "/etc/os-release"]) + .await + .expect("sandbox create from explicit NVIDIA Ubuntu image"); + + let clean_output = strip_ansi(&guard.create_output); + assert!( + clean_output.contains("ID=ubuntu"), + "expected Ubuntu OS release info in sandbox output:\n{clean_output}" + ); + + guard.cleanup().await; +} diff --git a/e2e/rust/tests/driver_config_volume.rs b/e2e/rust/tests/driver_config_volume.rs index 8aaf1e3cb..054ba3bab 100644 --- a/e2e/rust/tests/driver_config_volume.rs +++ b/e2e/rust/tests/driver_config_volume.rs @@ -21,7 +21,7 @@ use openshell_e2e::harness::container::{ImageGuard, e2e_driver}; use openshell_e2e::harness::sandbox::SandboxGuard; use serde_json::{Map, Value}; -const TEST_IMAGE: &str = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest"; +const TEST_IMAGE: &str = "nvcr.io/nvidia/base/ubuntu:24.04"; const VOLUME_TARGET: &str = "/sandbox/e2e-volume"; const BIND_TARGET: &str = "/sandbox/e2e-bind"; #[cfg(feature = "e2e-docker")] diff --git a/e2e/rust/tests/host_gateway_alias.rs b/e2e/rust/tests/host_gateway_alias.rs index e0df5d25c..e4ae81147 100644 --- a/e2e/rust/tests/host_gateway_alias.rs +++ b/e2e/rust/tests/host_gateway_alias.rs @@ -174,7 +174,7 @@ endpoints: protocol: rest access: full enforcement: enforce -binaries: [/usr/bin/curl] +binaries: [/usr/bin/bash] "# ); file.write_all(profile.as_bytes()) @@ -191,7 +191,7 @@ fn write_binding_policy(port: u16) -> Result { filesystem_policy: include_workdir: true - read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_only: [/bin, /usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] read_write: [/sandbox, /tmp, /dev/null] landlock: @@ -218,7 +218,7 @@ network_policies: access: full enforcement: enforce binaries: - - path: /usr/bin/curl + - path: /usr/bin/bash "# ); file.write_all(policy.as_bytes()) @@ -263,6 +263,7 @@ filesystem_policy: include_workdir: true read_only: - /usr + - /bin - /lib - /proc - /dev/urandom @@ -293,7 +294,7 @@ network_policies: - "192.168.0.0/16" - "fc00::/7" binaries: - - path: /usr/bin/curl + - path: /usr/bin/bash "# ); file.write_all(policy.as_bytes()) @@ -315,20 +316,17 @@ async fn sandbox_reaches_host_openshell_internal_via_host_gateway_alias() { .expect("temp policy path should be utf-8") .to_string(); - // The workload needs curl, which minimal default images such as the VM - // driver's nvcr.io/nvidia/base/ubuntu do not ship. + let command = format!( + r#"exec 3<>/dev/tcp/host.openshell.internal/{0}; printf 'GET / HTTP/1.1\r\nHost: host.openshell.internal:{0}\r\nConnection: close\r\n\r\n' >&3; while IFS= read -r line <&3 || [[ -n $line ]]; do printf '%s\n' "$line"; done"#, + server.port + ); let guard = SandboxGuard::create(&[ - "--from", - "base", "--policy", &policy_path, "--", - "curl", - "--silent", - "--show-error", - "--max-time", - "15", - &format!("http://host.openshell.internal:{}/", server.port), + "/usr/bin/bash", + "-c", + &command, ]) .await .expect("sandbox create with host.openshell.internal echo request"); @@ -404,12 +402,31 @@ async fn static_provider_credentials_are_bound_to_profile_endpoints() { .expect("create endpoint-bound provider B"); let command = format!( - r#"allowed=$(curl --silent --show-error --max-time 15 -H "Authorization: Bearer $BOUND_TOKEN_A" http://host.openshell.internal:{}/allowed/check); host_denied=$(curl --silent --show-error --max-time 15 -o /tmp/host-denied-body -w "%{{http_code}}" -H "Authorization: Bearer $BOUND_TOKEN_A" http://host.docker.internal:{}/allowed/check); path_denied=$(curl --silent --show-error --max-time 15 -o /tmp/path-denied-body -w "%{{http_code}}" -H "Authorization: Bearer $BOUND_TOKEN_A" http://host.openshell.internal:{}/other/check); printf 'ALLOWED=%s HOST_DENIED=%s PATH_DENIED=%s\n' "$allowed" "$host_denied" "$path_denied""#, - server.port, server.port, server.port + r#" +http_request() {{ + local host="$1" path="$2" status_line line + HTTP_STATUS= HTTP_BODY= + exec 3<>"/dev/tcp/$host/{0}" || return 1 + printf 'GET %s HTTP/1.1\r\nHost: %s:{0}\r\nAuthorization: Bearer %s\r\nConnection: close\r\n\r\n' "$path" "$host" "$BOUND_TOKEN_A" >&3 + IFS= read -r status_line <&3 || return 1 + status_line="${{status_line%$'\r'}}" + HTTP_STATUS="${{status_line#* }}" + HTTP_STATUS="${{HTTP_STATUS%% *}}" + while IFS= read -r line <&3; do + line="${{line%$'\r'}}" + [[ -z "$line" ]] && break + done + while IFS= read -r line <&3 || [[ -n "$line" ]]; do HTTP_BODY+="$line"; done + exec 3>&- 3<&- +}} +http_request host.openshell.internal /allowed/check; allowed="$HTTP_BODY" +http_request host.docker.internal /allowed/check; host_denied="$HTTP_STATUS" +http_request host.openshell.internal /other/check; path_denied="$HTTP_STATUS" +printf 'ALLOWED=%s HOST_DENIED=%s PATH_DENIED=%s\n' "$allowed" "$host_denied" "$path_denied" +"#, + server.port ); let mut guard = SandboxGuard::create(&[ - "--from", - "base", "--policy", &policy_path, "--provider", @@ -418,7 +435,7 @@ async fn static_provider_credentials_are_bound_to_profile_endpoints() { BINDING_PROVIDER_B_NAME, "--no-auto-providers", "--", - "sh", + "/usr/bin/bash", "-c", &command, ]) diff --git a/e2e/rust/tests/podman_oci_identity.rs b/e2e/rust/tests/podman_oci_identity.rs index e74888c84..c56c700d0 100644 --- a/e2e/rust/tests/podman_oci_identity.rs +++ b/e2e/rust/tests/podman_oci_identity.rs @@ -18,7 +18,7 @@ use openshell_e2e::harness::container::{ContainerEngine, is_e2e_driver}; use openshell_e2e::harness::output::strip_ansi; use openshell_e2e::harness::sandbox::SandboxGuard; -const BASE_IMAGE: &str = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest"; +const BASE_IMAGE: &str = "nvcr.io/nvidia/base/ubuntu:24.04"; const READY_MARKER: &str = "podman-oci-identity-ready"; const OCI_UID: &str = "2345"; const OCI_GID: &str = "2346"; @@ -182,7 +182,7 @@ async fn podman_uses_oci_identity_and_inspected_image_id() { } let image = ImageGuard::build().expect("build Podman OCI identity image"); - // The community base image contains a baked default policy with an + // The fixture image contains a policy with an // explicit `sandbox` process identity. Supply a complete policy that // intentionally omits `process` so this test exercises OCI fallback. let policy = tempfile::NamedTempFile::new().expect("create OCI fallback policy"); diff --git a/e2e/rust/tests/provider_auto_create.rs b/e2e/rust/tests/provider_auto_create.rs index 86d5d336c..43fdc2729 100644 --- a/e2e/rust/tests/provider_auto_create.rs +++ b/e2e/rust/tests/provider_auto_create.rs @@ -22,6 +22,8 @@ use std::sync::Mutex; use openshell_e2e::harness::binary::openshell_cmd; use openshell_e2e::harness::output::{extract_field, strip_ansi}; +#[cfg(feature = "e2e-docker")] +use openshell_e2e::harness::sandbox::E2E_WORKLOAD_IMAGE; const TEST_API_KEY: &str = "sk-e2e-auto-provider-test-key"; static CLAUDE_PROVIDER_LOCK: Mutex<()> = Mutex::new(()); @@ -108,9 +110,10 @@ network_policies: {} // Create a sandbox that prints the ANTHROPIC_API_KEY env var. // --auto-providers skips the interactive prompt. let mut cmd = openshell_cmd(); - cmd.arg("sandbox") - .arg("create") - .arg("--detach") + cmd.arg("sandbox").arg("create"); + #[cfg(feature = "e2e-docker")] + cmd.arg("--from").arg(E2E_WORKLOAD_IMAGE); + cmd.arg("--detach") .arg("--policy") .arg(policy.path()) .arg("--provider") diff --git a/e2e/rust/tests/provider_readiness.rs b/e2e/rust/tests/provider_readiness.rs index 58278faa5..700c4880d 100644 --- a/e2e/rust/tests/provider_readiness.rs +++ b/e2e/rust/tests/provider_readiness.rs @@ -20,7 +20,7 @@ use std::time::Duration; use openshell_e2e::harness::binary::openshell_cmd; use openshell_e2e::harness::container::{ContainerEngine, e2e_network_name}; use openshell_e2e::harness::gateway::ManagedGateway; -use openshell_e2e::harness::sandbox::SandboxGuard; +use openshell_e2e::harness::sandbox::{E2E_WORKLOAD_IMAGE, SandboxGuard}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tempfile::TempDir; @@ -1312,18 +1312,6 @@ async fn acknowledged_provider_changes_apply_to_fresh_clients_and_revoke_retaine std::fs::create_dir(&context).map_err(|_| "could not allocate public image context")?; let backend_tls = directory.path().join("backend-tls"); std::fs::create_dir(&backend_tls).map_err(|_| "could not allocate backend TLS directory")?; - let base = std::env::var("OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE") - .unwrap_or_else(|_| "ghcr.io/nvidia/openshell-community/sandboxes/base:latest".to_string()); - if base.chars().any(char::is_whitespace) { - return Err("fixture image reference contains whitespace".to_string()); - } - let binaries = base_binaries(&base).await?; - let python = binaries["python"] - .as_str() - .ok_or("Python executable was absent")?; - let curl = binaries["curl"] - .as_str() - .ok_or("curl executable was absent")?; let image = FixtureImage::new()?; let supervisor_image = FixtureImage::new()?; // Each backend has its own network namespace, so fixed internal ports need @@ -1340,7 +1328,28 @@ async fn acknowledged_provider_changes_apply_to_fresh_clients_and_revoke_retaine let result = async { // Begin container mutation inside this scope so certificate, image, // and enrollment failures still reach explicit bounded teardown. - let [host, other_host] = backend.spawn(&base, &backend_tls).await?; + std::fs::write(context.join("client.py"), CLIENT) + .map_err(|_| "could not write client source")?; + let dockerfile = context.join("Dockerfile"); + std::fs::write( + &dockerfile, + format!( + "FROM {E2E_WORKLOAD_IMAGE}\nCOPY client.py /opt/provider-readiness-client.py\nUSER 1000:1000\n", + ), + ) + .map_err(|_| "could not write fixture Dockerfile")?; + image + .build(&dockerfile, &context, "build workload fixture image") + .await?; + let binaries = base_binaries(image.tag()).await?; + let python = binaries["python"] + .as_str() + .ok_or("Python executable was absent")?; + let curl = binaries["curl"] + .as_str() + .ok_or("curl executable was absent")?; + + let [host, other_host] = backend.spawn(image.tag(), &backend_tls).await?; let (certificate, private_key) = generate_certificates(directory.path(), &host, &other_host).await?; std::fs::copy(&certificate, backend_tls.join("backend.crt")) @@ -1362,12 +1371,6 @@ async fn acknowledged_provider_changes_apply_to_fresh_clients_and_revoke_retaine .await?; std::fs::copy(directory.path().join("ca.crt"), context.join("fixture-ca.crt")) .map_err(|_| "could not copy public fixture CA")?; - std::fs::write(context.join("client.py"), CLIENT) - .map_err(|_| "could not write client source")?; - let dockerfile = context.join("Dockerfile"); - std::fs::write(&dockerfile, format!( - "FROM {base}\nUSER root\nCOPY client.py /opt/provider-readiness-client.py\nUSER sandbox\n" - )).map_err(|_| "could not write fixture Dockerfile")?; let supervisor_dockerfile = context.join("Dockerfile.supervisor"); // Outbound TLS belongs to the separate supervisor. Assemble its combined // public trust bundle in the shell-capable workload image because the @@ -1375,13 +1378,11 @@ async fn acknowledged_provider_changes_apply_to_fresh_clients_and_revoke_retaine // image's user setting: Docker's archive upload applies an explicit image // user to the supervisor's private bootstrap files. std::fs::write(&supervisor_dockerfile, format!( - "FROM {} AS supervisor\nFROM {base} AS trust-bundle\nUSER 0\nCOPY --from=supervisor /etc/ssl/certs/ca-certificates.crt /tmp/ca-certificates.crt\nCOPY fixture-ca.crt /tmp/readiness-fixture-ca.crt\nRUN [\"/usr/bin/python3\", \"-c\", \"from pathlib import Path; bundle = Path('/tmp/ca-certificates.crt'); bundle.write_bytes(bundle.read_bytes() + Path('/tmp/readiness-fixture-ca.crt').read_bytes())\"]\nFROM {}\nCOPY --from=trust-bundle /tmp/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt\n", + "FROM {} AS supervisor\nFROM {} AS trust-bundle\nUSER 0\nCOPY --from=supervisor /etc/ssl/certs/ca-certificates.crt /tmp/ca-certificates.crt\nCOPY fixture-ca.crt /tmp/readiness-fixture-ca.crt\nRUN [\"/usr/bin/python3\", \"-c\", \"from pathlib import Path; bundle = Path('/tmp/ca-certificates.crt'); bundle.write_bytes(bundle.read_bytes() + Path('/tmp/readiness-fixture-ca.crt').read_bytes())\"]\nFROM {}\nCOPY --from=trust-bundle /tmp/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt\n", gateway_config.supervisor_image, + image.tag(), gateway_config.supervisor_image, )).map_err(|_| "could not write fixture supervisor Dockerfile")?; - image - .build(&dockerfile, &context, "build workload fixture image") - .await?; supervisor_image .build( &supervisor_dockerfile, diff --git a/e2e/rust/tests/provider_refresh_handles.rs b/e2e/rust/tests/provider_refresh_handles.rs index 78e1adfe7..b853d3595 100644 --- a/e2e/rust/tests/provider_refresh_handles.rs +++ b/e2e/rust/tests/provider_refresh_handles.rs @@ -106,7 +106,7 @@ fn write_profile(resource_port: u16, token_port: u16) -> Result Result { .tempfile() .map_err(|error| format!("create policy: {error}"))?; let policy = format!( - r#"version: 1 + r"version: 1 filesystem_policy: include_workdir: true read_only: [/usr, /lib, /proc, /etc, /dev/urandom] @@ -181,8 +181,8 @@ network_policies: - 172.0.0.0/8 - 192.168.0.0/16 binaries: - - path: /usr/bin/curl -"# + - path: /usr/local/bin/python3 +" ); file.write_all(policy.as_bytes()) .map_err(|error| format!("write policy: {error}"))?; @@ -311,9 +311,7 @@ echo {READY_MARKER} while true; do if [ -f /sandbox/probe-trigger ]; then rm -f /sandbox/probe-trigger - if curl --fail --silent --output /dev/null \ - --header "Authorization: Bearer $REFRESH_E2E_ACCESS_TOKEN" \ - {resource_url}; then + if python3 -c 'import os, urllib.request; request = urllib.request.Request("{resource_url}", headers=dict(Authorization="Bearer " + os.environ["REFRESH_E2E_ACCESS_TOKEN"])); urllib.request.urlopen(request, timeout=5).read()'; then echo ok > /sandbox/probe-result else echo failed > /sandbox/probe-result @@ -343,7 +341,7 @@ done"# wait_for_probe_failure(&sandbox).await?; let fresh_probe = format!( - "curl --fail --silent --output /dev/null --header \"Authorization: Bearer $REFRESH_E2E_ACCESS_TOKEN\" {resource_url}" + r#"python3 -c 'import os, urllib.request; request = urllib.request.Request("{resource_url}", headers=dict(Authorization="Bearer " + os.environ["REFRESH_E2E_ACCESS_TOKEN"])); urllib.request.urlopen(request, timeout=5).read()'"# ); sandbox.exec(&["sh", "-c", &fresh_probe]).await?; Ok(()) diff --git a/e2e/rust/tests/provider_token_exchange.rs b/e2e/rust/tests/provider_token_exchange.rs index b6340d646..4d2dd703f 100644 --- a/e2e/rust/tests/provider_token_exchange.rs +++ b/e2e/rust/tests/provider_token_exchange.rs @@ -554,7 +554,7 @@ fn write_profile(profile_type: &str, token_port: u16, target_port: u16) -> Named .tempfile() .expect("create provider profile temp file"); let profile = format!( - r#"id: {profile_type} + r"id: {profile_type} display_name: Podman token exchange e2e description: Podman e2e provider profile for two-stage token exchange category: other @@ -592,9 +592,8 @@ endpoints: - 172.0.0.0/8 - 192.168.0.0/16 binaries: - - /usr/bin/curl - - /usr/local/bin/curl -"# + - /usr/local/bin/python3 +" ); file.write_all(profile.as_bytes()) .expect("write provider profile"); @@ -604,10 +603,10 @@ binaries: fn sandbox_script(token_port: u16) -> String { let _ = token_port; - r#"set -eu + r"set -eu echo token-server-ready while true; do sleep 60; done -"# +" .to_string() } @@ -773,8 +772,11 @@ async fn container_loopback_port_ready(container_name: &str, token_port: u16) -> .unwrap_or(false) } -async fn sandbox_exec_curl(sandbox_name: &str, target_port: u16) -> Result { +async fn sandbox_exec_http(sandbox_name: &str, target_port: u16) -> Result { let url = format!("http://host.openshell.internal:{target_port}/resource"); + let script = format!( + "import urllib.request; print(urllib.request.urlopen({url:?}, timeout=5).read().decode())" + ); let mut last_output = String::new(); for _ in 0..20 { let output = openshell_cmd() @@ -785,11 +787,9 @@ async fn sandbox_exec_curl(sandbox_name: &str, target_port: u16) -> Result Result output, Err(err) => { let debug = provider_token_debug(&sandbox.name, token_port, target_port).await; - panic!("curl protected target from kept sandbox: {err}\n{debug}"); + panic!("request protected target from kept sandbox: {err}\n{debug}"); } }; @@ -887,8 +887,8 @@ async fn podman_provider_token_exchange_injects_bearer_header() { sandbox.cleanup().await; assert!( - curl_output.contains("token-exchange-ok"), + request_output.contains("token-exchange-ok"), "protected target should receive the final exchanged bearer token:\n{}", - curl_output + request_output ); } diff --git a/e2e/rust/tests/vm_corporate_proxy.rs b/e2e/rust/tests/vm_corporate_proxy.rs index 1b7b58802..1e34e71d0 100644 --- a/e2e/rust/tests/vm_corporate_proxy.rs +++ b/e2e/rust/tests/vm_corporate_proxy.rs @@ -738,20 +738,10 @@ async fn vm_corporate_proxy_routes_approved_tls_egress() { // ── Run the workload ────────────────────────────────────────────── let (_policy, policy_path) = temp_file_with(&policy_yaml(&ports), "policy file"); let script = workload_script(&ports); - // The workload runs python3, which the VM driver's default - // nvcr.io/nvidia/base/ubuntu image does not ship. - let mut sandbox = SandboxGuard::create(&[ - "--from", - "base", - "--policy", - &policy_path, - "--", - "python3", - "-c", - &script, - ]) - .await - .expect("create VM sandbox behind the corporate proxy"); + let mut sandbox = + SandboxGuard::create(&["--policy", &policy_path, "--", "python3", "-c", &script]) + .await + .expect("create VM sandbox behind the corporate proxy"); assert_proxied_egress( &sandbox.create_output, @@ -815,18 +805,10 @@ async fn vm_corporate_proxy_trusts_ca_bundle_for_https_proxy() { let (_policy, policy_path) = temp_file_with(&policy_yaml(&ports), "policy file"); let script = workload_script(&ports); - let mut sandbox = SandboxGuard::create(&[ - "--from", - "base", - "--policy", - &policy_path, - "--", - "python3", - "-c", - &script, - ]) - .await - .expect("create VM sandbox behind the https corporate proxy"); + let mut sandbox = + SandboxGuard::create(&["--policy", &policy_path, "--", "python3", "-c", &script]) + .await + .expect("create VM sandbox behind the https corporate proxy"); let proxy_logs = proxy.logs().expect("read https proxy logs"); assert!( diff --git a/e2e/with-docker-gateway.sh b/e2e/with-docker-gateway.sh index 7334d31f7..e4d0d8128 100755 --- a/e2e/with-docker-gateway.sh +++ b/e2e/with-docker-gateway.sh @@ -18,7 +18,7 @@ # OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE=... # OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE_PULL_POLICY=always|if_not_present|never # -# The default community sandbox image uses :latest. This wrapper refreshes it +# The default sandbox image uses a mutable tag. This wrapper refreshes it # before starting the gateway, while the Docker driver defaults to # if_not_present so local Dockerfile-built images remain usable. # @@ -516,7 +516,7 @@ build_local_docker_sandbox_runtime_image_if_required "${SANDBOX_RUNTIME_IMAGE}" ensure_docker_sandbox_runtime_image "${SANDBOX_RUNTIME_IMAGE}" echo "Using Docker sandbox runtime image: ${SANDBOX_RUNTIME_IMAGE}" -DEFAULT_SANDBOX_IMAGE="ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +DEFAULT_SANDBOX_IMAGE="nvcr.io/nvidia/base/ubuntu:24.04" SANDBOX_IMAGE="${OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE:-${OPENSHELL_SANDBOX_IMAGE:-${DEFAULT_SANDBOX_IMAGE}}}" SANDBOX_IMAGE_PULL_POLICY="${OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE_PULL_POLICY:-${OPENSHELL_SANDBOX_IMAGE_PULL_POLICY:-if_not_present}}" if ! ensure_sandbox_image_available "${SANDBOX_IMAGE}"; then diff --git a/e2e/with-podman-gateway.sh b/e2e/with-podman-gateway.sh index f851c9295..97cb750dd 100755 --- a/e2e/with-podman-gateway.sh +++ b/e2e/with-podman-gateway.sh @@ -613,7 +613,7 @@ SANDBOX_BOUNDARY_IMAGE="$(resolve_podman_sandbox_runtime_image)" ensure_podman_sandbox_runtime_image "${SANDBOX_BOUNDARY_IMAGE}" echo "Using Podman sandbox runtime image: ${SANDBOX_BOUNDARY_IMAGE}" -DEFAULT_SANDBOX_IMAGE="ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +DEFAULT_SANDBOX_IMAGE="nvcr.io/nvidia/base/ubuntu:24.04" SANDBOX_IMAGE_REQUEST="${OPENSHELL_E2E_PODMAN_SANDBOX_IMAGE:-${OPENSHELL_SANDBOX_IMAGE:-${DEFAULT_SANDBOX_IMAGE}}}" if [ "${OPENSHELL_E2E_REQUIRE_DIGEST_PINNED_SANDBOX_IMAGE:-0}" = "1" ] \ && ! [[ "${SANDBOX_IMAGE_REQUEST}" =~ ^[^@]+@sha256:[0-9a-f]{64}$ ]]; then diff --git a/examples/agent-driven-policy-management/demo.sh b/examples/agent-driven-policy-management/demo.sh index b123c66ad..2cb10dedb 100755 --- a/examples/agent-driven-policy-management/demo.sh +++ b/examples/agent-driven-policy-management/demo.sh @@ -362,7 +362,6 @@ start_agent_sandbox() { ( "$OPENSHELL_BIN" sandbox create \ --name "$DEMO_SANDBOX_NAME" \ - --from base \ --provider "$DEMO_CODEX_PROVIDER_NAME" \ --provider "$DEMO_GITHUB_PROVIDER_NAME" \ --policy "$POLICY_FILE" \ diff --git a/examples/aws-s3-sts.md b/examples/aws-s3-sts.md index 94b73c73c..76bace9f3 100644 --- a/examples/aws-s3-sts.md +++ b/examples/aws-s3-sts.md @@ -104,7 +104,7 @@ gateway_id = "podman-dev" ttl_secs = 3600 [openshell.drivers.podman] -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" supervisor_image = "localhost/openshell/supervisor:dev" image_pull_policy = "if_not_present" health_check_interval_secs = 10 diff --git a/examples/multi-agent-notepad/README.md b/examples/multi-agent-notepad/README.md index 2bff46cd3..910128316 100644 --- a/examples/multi-agent-notepad/README.md +++ b/examples/multi-agent-notepad/README.md @@ -85,6 +85,7 @@ for this demo, or one you're comfortable with the demo creating files in. export DEMO_GITHUB_OWNER= export DEMO_GITHUB_REPO= export DEMO_GITHUB_TOKEN="$(gh auth token)" +export DEMO_AGENT_IMAGE="registry.example.com/your-org/codex-agent:latest" bash examples/multi-agent-notepad/demo.sh ``` @@ -93,9 +94,9 @@ bash examples/multi-agent-notepad/demo.sh usually broader than `contents:write`. If you'd rather use a scope-limited PAT, set `DEMO_GITHUB_TOKEN` to that value instead. -By default the script launches five worker agents and one synthesis agent in -the OpenShell `base` image, where Codex is preinstalled. To run a faster -smoke test: +The selected image must contain Codex, `curl`, and Bash. The script launches +five worker agents and one synthesis agent by default. To run a faster smoke +test: ```bash export DEMO_AGENT_COUNT=2 @@ -139,7 +140,7 @@ values never sit in the sandbox filesystem. The script renders `policy.template.yaml` for the configured GitHub repository and run id. The policy allows: -- Codex traffic to OpenAI and ChatGPT endpoints used by the community base +- Codex traffic to OpenAI and ChatGPT endpoints used by the selected workload image - Limited Codex plugin metadata reads from `github.com/openai/plugins.git` - GitHub REST `GET` and `PUT` calls scoped to diff --git a/examples/multi-agent-notepad/demo.sh b/examples/multi-agent-notepad/demo.sh index 0d68b3cdb..53535a644 100755 --- a/examples/multi-agent-notepad/demo.sh +++ b/examples/multi-agent-notepad/demo.sh @@ -12,6 +12,7 @@ PROMPTS_DIR="${SCRIPT_DIR}/prompts" OPENSHELL_BIN="${OPENSHELL_BIN:-openshell}" DEMO_TOPIC="${DEMO_TOPIC:-How should teams evaluate sandboxed coding agents?}" DEMO_AGENT_COUNT="${DEMO_AGENT_COUNT:-5}" +DEMO_AGENT_IMAGE="${DEMO_AGENT_IMAGE:-}" DEMO_BRANCH="${DEMO_BRANCH:-main}" DEMO_RUN_ID="${DEMO_RUN_ID:-$(date +%Y%m%d-%H%M%S)}" # Sandbox names are capped at 19 characters. Derive a short tag from the @@ -157,9 +158,10 @@ create_providers() { run_sandbox() { local name="$1" shift + [[ -n "$DEMO_AGENT_IMAGE" ]] || fail "set DEMO_AGENT_IMAGE to an OCI image containing Codex, curl, and bash" "$OPENSHELL_BIN" sandbox create \ --name "$name" \ - --from base \ + --from "$DEMO_AGENT_IMAGE" \ --provider "$DEMO_CODEX_PROVIDER_NAME" \ --provider "$DEMO_GITHUB_PROVIDER_NAME" \ --policy "$POLICY_FILE" \ diff --git a/examples/private-ip-routing/README.md b/examples/private-ip-routing/README.md index aae18d7ae..148871c2e 100644 --- a/examples/private-ip-routing/README.md +++ b/examples/private-ip-routing/README.md @@ -11,8 +11,8 @@ When an endpoint in the sandbox policy includes an `allowed_ips` field, the proxy validates the resolved IP against that CIDR allowlist instead of blanket-blocking. Loopback and link-local remain always-blocked regardless. -The default sandbox policy (baked into the community base image) includes a `cluster_pods` -entry that allows any binary to reach port 8080 on the configured pod network: +Save an explicit policy as `private-policy.yaml` that allows the client binary +to reach port 8080 on the configured pod network: ```yaml cluster_pods: @@ -62,7 +62,10 @@ Create a sandbox and curl the private API through the proxy. Replace the IP with whatever `kubectl get pod` showed above: ```bash -openshell sandbox create -- bash -c \ +openshell sandbox create \ + --from registry.example.com/tools/curl:latest \ + --policy ./private-policy.yaml \ + -- bash -c \ 'curl -s --proxytunnel -x http://10.200.0.1:3128 http://10.42.0.128:8080/' ``` diff --git a/examples/spiffe-token-exchange-demo/podman/README.md b/examples/spiffe-token-exchange-demo/podman/README.md index 197fe70cd..2fc379fa4 100644 --- a/examples/spiffe-token-exchange-demo/podman/README.md +++ b/examples/spiffe-token-exchange-demo/podman/README.md @@ -109,7 +109,7 @@ TOKEN_ISSUER_PORT=18080 MANAGED_GATEWAY_PORT=18082 MANAGED_GATEWAY_HEALTH_PORT=18083 GATEWAY_IMAGE=ghcr.io/nvidia/openshell/gateway:latest -SANDBOX_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:latest +SANDBOX_IMAGE=nvcr.io/nvidia/base/ubuntu:24.04 SUPERVISOR_IMAGE=ghcr.io/nvidia/openshell/supervisor:latest SANDBOX_IMAGE_PULL_POLICY=if_not_present PODMAN_STOP_TIMEOUT_SECS=3 diff --git a/examples/sync-files.md b/examples/sync-files.md index 2047df2ce..d50e4065e 100644 --- a/examples/sync-files.md +++ b/examples/sync-files.md @@ -66,9 +66,8 @@ openshell sandbox download dev /sandbox/coverage ./coverage ## How it works -File sync uses **tar-over-SSH**. The CLI streams a tar archive through the -existing SSH proxy tunnel -- no `rsync` or other external tools required on -your machine. The sandbox base image provides GNU `tar` for extraction. - -- **Push**: `tar::Builder` (Rust) -> stdin | `ssh sandbox "tar xf - -C "` -- **Pull**: `ssh sandbox "tar cf - -C "` | stdout -> `tar::Archive` (Rust) +File sync uses the native OpenShell streaming file-transfer protocol. The CLI +creates and extracts tar streams in Rust, and the sandbox performs the matching +operation under the workload identity. Transfers use bounded frames with +backpressure, cancellation, and explicit completion; they do not require +`ssh`, `scp`, `rsync`, or a `tar` executable in the workload image. diff --git a/nix/test-guest/provisioners/roles/gateway-podman/tasks/development-gateway.yml b/nix/test-guest/provisioners/roles/gateway-podman/tasks/development-gateway.yml index 939400f5f..d1959ec51 100644 --- a/nix/test-guest/provisioners/roles/gateway-podman/tasks/development-gateway.yml +++ b/nix/test-guest/provisioners/roles/gateway-podman/tasks/development-gateway.yml @@ -56,7 +56,7 @@ gateway_id = "openshell-test-guest" [openshell.drivers.podman] - default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" + default_image = "nvcr.io/nvidia/base/ubuntu:24.04" supervisor_image = "{{ openshell_supervisor_image }}" image_pull_policy = "always" network_name = "openshell-test-guest" diff --git a/providers/README.md b/providers/README.md index 740bb05f1..c7a576264 100644 --- a/providers/README.md +++ b/providers/README.md @@ -30,7 +30,7 @@ Every file opens with a comment block naming its expected client binaries, the image layout those paths assume, the credential scope, the endpoint access it grants, and a smoke test. Read it. A profile's `binaries` list is the control that decides which processes may reach its endpoints, and several of these -examples name paths that only exist in the OpenShell Community image +examples name paths from a particular reference image layout (`/sandbox/.venv`, `/app/.venv`, `/sandbox/.cursor-server`, `/usr/lib/node_modules/...`). Imported unchanged into a different image, such a profile matches nothing: the catalog still advertises it, but the credential is diff --git a/providers/aws-s3.yaml b/providers/aws-s3.yaml index a3f13625c..0cd973e6b 100644 --- a/providers/aws-s3.yaml +++ b/providers/aws-s3.yaml @@ -11,7 +11,7 @@ # # Client binaries: python, python3, curl, aws, bash. # Reference layout: the venv and uv paths below (/sandbox/.venv, -# /sandbox/.uv/python) are OpenShell Community image paths, +# /sandbox/.uv/python) come from one reference image layout, # and /usr/local/bin/aws assumes the AWS CLI v2 installer. # Edit `binaries` to match your image before importing. # `binaries` also matches a process's ancestors, so the diff --git a/providers/codex.yaml b/providers/codex.yaml index c9572a0a1..bafc5bd06 100644 --- a/providers/codex.yaml +++ b/providers/codex.yaml @@ -12,7 +12,7 @@ # Client binaries: codex. # Reference layout: /usr/bin/codex or /usr/local/bin/codex, plus the npm global # install at /usr/lib/node_modules/@openai/**. The npm path is -# an OpenShell Community image layout; check where `npm root +# one reference image layout; check where `npm root # -g` points in your image. # Credential scope: the CODEX_AUTH_* OAuth tokens and account id. They are # injected into the sandbox environment for the codex CLI to diff --git a/providers/copilot.yaml b/providers/copilot.yaml index 7a9419a1f..abb8373e0 100644 --- a/providers/copilot.yaml +++ b/providers/copilot.yaml @@ -12,7 +12,7 @@ # Client binaries: copilot. # Reference layout: /usr/bin/copilot plus the npm global install under # /usr/lib/node_modules/@github/copilot/**. That npm path is -# an OpenShell Community image layout; check `npm root -g` in +# one reference image layout; check `npm root -g` in # your image. # Credential scope: COPILOT_GITHUB_TOKEN, GH_TOKEN or GITHUB_TOKEN, sent as a # bearer authorization header to the Copilot API hosts below. diff --git a/providers/cursor.yaml b/providers/cursor.yaml index 8f6061938..2834ff421 100644 --- a/providers/cursor.yaml +++ b/providers/cursor.yaml @@ -11,8 +11,8 @@ # # Client binaries: curl, wget, and the Cursor remote server. # Reference layout: the server path below (/sandbox/.cursor-server/**) is where -# Cursor unpacks itself when it connects to an OpenShell -# Community image over SSH. A different HOME or user means a +# Cursor may unpack itself when it connects over SSH. A +# different HOME or user means a # different path — check where the server actually lands. # Credential scope: none. This profile only opens the bootstrap and update # endpoints; Cursor authenticates the user separately. diff --git a/providers/pypi.yaml b/providers/pypi.yaml index 73c9573e1..78ceaef9a 100644 --- a/providers/pypi.yaml +++ b/providers/pypi.yaml @@ -10,7 +10,7 @@ # below, so it has to name the paths in *your* image. # # Client binaries: python, python3, pip, uv. -# Reference layout: the paths below are OpenShell Community image paths — a +# Reference layout: the paths below assume a particular image layout — a # virtualenv at /sandbox/.venv or /app/.venv, uv at # /usr/local/bin/uv, uv-managed interpreters under # /sandbox/.uv/python. Almost every other image differs diff --git a/python/openshell/gateway_schema_v2_live_results_test.py b/python/openshell/gateway_schema_v2_live_results_test.py index 3cb52c5d6..cfd0a0151 100644 --- a/python/openshell/gateway_schema_v2_live_results_test.py +++ b/python/openshell/gateway_schema_v2_live_results_test.py @@ -85,7 +85,7 @@ STEP_10_CANDIDATE_COMMIT = EXPECTED_EXECUTED_CANDIDATE_COMMITS[ "compute-driver-boundary-parity" ] STEP_10_REPORT_SHA256 = ( - "65541eec5f642461a88b04b5459474fd7a475adeb7071a65a53fe183caad6a01" + "3c1297eef6c0a3b22530b980b571bda2d967a39a79a3e3286bcc09e30df84bf3" ) STEP_10_EVIDENCE_BUNDLES = { "in_tree": "target/parity/step10-intree-4a39da51", diff --git a/rfc/0003-gateway-configuration/README.md b/rfc/0003-gateway-configuration/README.md index 8007236d7..4527c85d0 100644 --- a/rfc/0003-gateway-configuration/README.md +++ b/rfc/0003-gateway-configuration/README.md @@ -122,7 +122,7 @@ scopes_claim = "" # empty disables scope enforcement [openshell.drivers.kubernetes] namespace = "openshell" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" image_pull_policy = "if_not_present" supervisor_image = "ghcr.io/nvidia/openshell/supervisor:latest" supervisor_image_pull_policy = "if_not_present" @@ -132,7 +132,7 @@ host_gateway_ip = "10.0.0.1" ssh_socket_path = "/run/openshell/ssh.sock" [openshell.drivers.docker] -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" image_pull_policy = "if_not_present" sandbox_label = "docker-dev" grpc_endpoint = "https://host.openshell.internal:8080" @@ -142,7 +142,7 @@ supervisor_image = "ghcr.io/nvidia/openshell/supervisor:latest" # used to [openshell.drivers.podman] socket_path = "/run/podman/podman.sock" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" image_pull_policy = "if_not_present" # always | if_not_present | never | newer supervisor_image = "ghcr.io/nvidia/openshell/supervisor:latest" network_name = "openshell" @@ -225,7 +225,7 @@ disable_tls = true [openshell.drivers.kubernetes] namespace = "agents" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +default_image = "nvcr.io/nvidia/base/ubuntu:24.04" supervisor_image = "ghcr.io/nvidia/openshell/supervisor:0.9.0" grpc_endpoint = "https://openshell-gateway.agents.svc:8080" ``` @@ -245,7 +245,7 @@ gateway: drivers: kubernetes: namespace: agents - default_image: ghcr.io/nvidia/openshell-community/sandboxes/base:latest + default_image: nvcr.io/nvidia/base/ubuntu:24.04 supervisor_image: ghcr.io/nvidia/openshell/supervisor:0.9.0 ``` diff --git a/scripts/agents/gator/Dockerfile b/scripts/agents/gator/Dockerfile index 6484746db..db1f5218a 100644 --- a/scripts/agents/gator/Dockerfile +++ b/scripts/agents/gator/Dockerfile @@ -5,7 +5,7 @@ # Gator sandbox image. # -# This mirrors the OpenShell Community base image's core system and developer +# This installs the core system and developer # tooling, but keeps the initial agent surface focused on Codex + GitHub tooling # for the gator-gate workflow. @@ -17,7 +17,7 @@ ENV DEBIAN_FRONTEND=noninteractive \ WORKDIR /sandbox -# Core system dependencies copied from the community base sandbox image. +# Core system dependencies required by the gator workload. # iproute2: network namespace management (ip netns, veth pairs) # iptables: legacy bypass detection (kept for transition) # nftables: bypass detection; log + reject rules for direct connection diagnostics @@ -43,7 +43,7 @@ RUN groupadd -r supervisor && useradd -r -g supervisor -s /usr/sbin/nologin supe FROM system AS devtools # Node.js 22 + build toolchain. Keep the default apt installs aligned with the -# community base image, then add the small CLI tools gator commonly needs. +# workload, then add the small CLI tools gator commonly needs. RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ apt-get install -y --no-install-recommends \ build-essential \ diff --git a/scripts/agents/gator/README.md b/scripts/agents/gator/README.md index b1b821963..a683a2030 100644 --- a/scripts/agents/gator/README.md +++ b/scripts/agents/gator/README.md @@ -4,7 +4,7 @@ Launch a headless sandbox agent that runs the `gator-gate` skill against OpenShe ## Prerequisites -- `gh` is authenticated on the host and has access to `NVIDIA/OpenShell` and `NVIDIA/OpenShell-Community`. +- `gh` is authenticated on the host and has access to `NVIDIA/OpenShell`. - For `--harness codex`, `codex login` has created `$HOME/.codex/auth.json`. - For `--harness codex`, local Codex auth must include an access token, refresh token, and account ID. - A local gateway and either Docker or Podman are available to build the diff --git a/scripts/agents/gator/prompts/gator.md b/scripts/agents/gator/prompts/gator.md index fee69f38d..27cba2a0c 100644 --- a/scripts/agents/gator/prompts/gator.md +++ b/scripts/agents/gator/prompts/gator.md @@ -10,7 +10,7 @@ Load and follow this skill exactly: Important sandbox constraints: -- GitHub REST write access is scoped to NVIDIA/OpenShell and NVIDIA/OpenShell-Community. +- GitHub REST write access is scoped to NVIDIA/OpenShell. - GitHub GraphQL access is read-only except for the named `ResolveGatorReviewThread` mutation used by `resolve-gator-review-threads`. Prefer REST endpoints for all other write diff --git a/scripts/agents/gator/providers/github-gator.yaml b/scripts/agents/gator/providers/github-gator.yaml index d0c1f32d4..5d8e30238 100644 --- a/scripts/agents/gator/providers/github-gator.yaml +++ b/scripts/agents/gator/providers/github-gator.yaml @@ -28,7 +28,6 @@ endpoints: q: any: - "*repo:NVIDIA/OpenShell*" - - "*repo:NVIDIA/OpenShell-Community*" - allow: { method: GET, path: /repos/NVIDIA/OpenShell } - allow: { method: GET, path: /repos/NVIDIA/OpenShell/** } - allow: { method: POST, path: /repos/NVIDIA/OpenShell/issues/*/comments } @@ -43,20 +42,6 @@ endpoints: - allow: { method: POST, path: /repos/NVIDIA/OpenShell/pulls/*/reviews } - allow: { method: POST, path: /repos/NVIDIA/OpenShell/pulls/*/reviews/*/comments } - allow: { method: POST, path: /repos/NVIDIA/OpenShell/statuses/* } - - allow: { method: GET, path: /repos/NVIDIA/OpenShell-Community } - - allow: { method: GET, path: /repos/NVIDIA/OpenShell-Community/** } - - allow: { method: POST, path: /repos/NVIDIA/OpenShell-Community/issues/*/comments } - - allow: { method: PATCH, path: /repos/NVIDIA/OpenShell-Community/issues/comments/* } - - allow: { method: POST, path: /repos/NVIDIA/OpenShell-Community/issues/*/labels } - - allow: { method: PUT, path: /repos/NVIDIA/OpenShell-Community/issues/*/labels } - - allow: { method: DELETE, path: /repos/NVIDIA/OpenShell-Community/issues/*/labels/* } - - allow: { method: PATCH, path: /repos/NVIDIA/OpenShell-Community/issues/* } - - allow: { method: POST, path: /repos/NVIDIA/OpenShell-Community/labels } - - allow: { method: PATCH, path: /repos/NVIDIA/OpenShell-Community/labels/* } - - allow: { method: POST, path: /repos/NVIDIA/OpenShell-Community/pulls/*/comments } - - allow: { method: POST, path: /repos/NVIDIA/OpenShell-Community/pulls/*/reviews } - - allow: { method: POST, path: /repos/NVIDIA/OpenShell-Community/pulls/*/reviews/*/comments } - - allow: { method: POST, path: /repos/NVIDIA/OpenShell-Community/statuses/* } - host: api.github.com port: 443 path: /graphql @@ -79,18 +64,12 @@ endpoints: - allow: { method: GET, path: /NVIDIA/OpenShell.git/** } - allow: { method: POST, path: /NVIDIA/OpenShell/**/git-upload-pack } - allow: { method: POST, path: /NVIDIA/OpenShell.git/**/git-upload-pack } - - allow: { method: GET, path: /NVIDIA/OpenShell-Community } - - allow: { method: GET, path: /NVIDIA/OpenShell-Community/** } - - allow: { method: GET, path: /NVIDIA/OpenShell-Community.git/** } - - allow: { method: POST, path: /NVIDIA/OpenShell-Community/**/git-upload-pack } - - allow: { method: POST, path: /NVIDIA/OpenShell-Community.git/**/git-upload-pack } - host: codeload.github.com port: 443 protocol: rest enforcement: enforce rules: - allow: { method: GET, path: /NVIDIA/OpenShell/** } - - allow: { method: GET, path: /NVIDIA/OpenShell-Community/** } - host: results-receiver.actions.githubusercontent.com port: 443 protocol: rest diff --git a/sdk/go/README.md b/sdk/go/README.md index 6d695c6b4..8e82aa2c3 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -278,6 +278,18 @@ consumers import a single package. See the [Architecture](https://ro14nd.de/open ## Features +Use `CloseInteractiveInput(session)` to close stdin and resize input while keeping +output readable. SDK sessions implement the optional `InteractiveSessionControl` +interface (`CloseWrite()` and `Cancel()`); the original `InteractiveSession` +interface remains unchanged for existing mocks and wrappers. Input closure returns +`ErrorUnimplemented` for sessions without that capability and leaves them open. +`CancelInteractive(session)` uses `Cancel()` when available and otherwise calls +`Close()`. SDK close/cancel operations are idempotent; writes and resizes after +input closure return `io.ErrClosedPipe`. Drain `Read` concurrently with waiting for `ExitCode()`. +`ExitCode()` waits for final gRPC status and returns any observed process exit code +alongside a later stream error. An exit event alone does not establish successful +stream completion. + | Feature | Interface | Docs | |---------|-----------|------| | Sandbox lifecycle (create, get, list, delete, watch, wait) | `SandboxInterface` | [Sandboxes](https://ro14nd.de/openshell-sdk-go/api/sandboxes.html) | diff --git a/sdk/go/docs/src/api/exec.md b/sdk/go/docs/src/api/exec.md index 0d088a699..72ff49b34 100644 --- a/sdk/go/docs/src/api/exec.md +++ b/sdk/go/docs/src/api/exec.md @@ -139,8 +139,22 @@ type InteractiveSession interface { | `Read` | Reads output from the process into the provided buffer. | | `Write` | Sends input to the process. | | `Resize` | Updates the terminal dimensions (columns and rows). | -| `ExitCode` | Returns the process exit code after the session ends. | -| `Close` | Closes the session and releases resources. | +| `ExitCode` | Waits for final gRPC status; returns any observed exit code alongside a later stream error. | +| `Close` | Aborts the RPC and releases resources. | + +SDK sessions also implement the optional `InteractiveSessionControl` interface, +which embeds `InteractiveSession` and adds `CloseWrite() error` and `Cancel() error`. +Existing custom implementations only need the original methods above. + +Call `CloseInteractiveInput(session)` to end stdin and resize input without +cancelling output. It invokes `CloseWrite()` when supported; otherwise it returns +`ErrorUnimplemented` without closing the session. Call `CancelInteractive(session)` +to invoke `Cancel()` when supported, falling back to `Close()` for legacy sessions. +The SDK's close and cancel methods are idempotent. + +Drain `Read` concurrently with waiting for `ExitCode`; bounded output buffers can +otherwise prevent completion. After input closure, writes and resizes return +`io.ErrClosedPipe`. Request EOF is not equivalent to a terminal Ctrl-D keystroke. ## ExecChunk diff --git a/sdk/go/openshell/v1/exec.go b/sdk/go/openshell/v1/exec.go index 217a1dc9b..f30975d15 100644 --- a/sdk/go/openshell/v1/exec.go +++ b/sdk/go/openshell/v1/exec.go @@ -27,10 +27,40 @@ type InteractiveSession interface { Read(p []byte) (int, error) Write(p []byte) (int, error) Resize(cols, rows uint32) error + // ExitCode waits for final stream completion. An observed exit code is + // returned alongside any later transport error. Drain Read concurrently. ExitCode() (int, error) Close() error } +// InteractiveSessionControl adds optional input closure and cancellation to +// InteractiveSession without requiring existing implementations to add methods. +// Sessions returned by this SDK implement both interfaces. +type InteractiveSessionControl interface { + InteractiveSession + // CloseWrite ends stdin and resize input without cancelling output. + CloseWrite() error + // Cancel aborts the RPC. Close retains the same full-close behavior. + Cancel() error +} + +// CloseInteractiveInput ends input while preserving output when supported. +// Unsupported sessions return ErrorUnimplemented and are left open. +func CloseInteractiveInput(session InteractiveSession) error { + if closer, ok := session.(interface{ CloseWrite() error }); ok { + return closer.CloseWrite() + } + return &StatusError{Code: ErrorUnimplemented, Message: "interactive session does not support closing input independently"} +} + +// CancelInteractive aborts a session, falling back to the original Close contract. +func CancelInteractive(session InteractiveSession) error { + if canceler, ok := session.(interface{ Cancel() error }); ok { + return canceler.Cancel() + } + return session.Close() +} + // ExecInterface defines command execution operations on sandboxes. // Methods accept a sandbox name and resolve it to an ID internally. type ExecInterface interface { diff --git a/sdk/go/openshell/v1/exec_client.go b/sdk/go/openshell/v1/exec_client.go index 8bc9c803b..1adc4c29d 100644 --- a/sdk/go/openshell/v1/exec_client.go +++ b/sdk/go/openshell/v1/exec_client.go @@ -11,6 +11,7 @@ import ( "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" "google.golang.org/grpc" + "google.golang.org/grpc/status" ) type execClient struct { @@ -174,19 +175,21 @@ func (s *execStream) Close() error { // interactiveSession wraps a bidirectional streaming RPC into the InteractiveSession interface. // A background goroutine owns the Recv loop and routes events to dataCh (for Read) -// and exitCh (for ExitCode), preventing concurrent Recv calls on the stream. +// and publishes the final exit/status through done, preventing concurrent Recv calls. type interactiveSession struct { - stream grpc.BidiStreamingClient[pb.ExecSandboxInput, pb.ExecSandboxEvent] - cancel context.CancelFunc - sendMu sync.Mutex - dataCh chan []byte - exitCh chan int - done chan struct{} - errOnce sync.Once - err error - buf []byte + stream grpc.BidiStreamingClient[pb.ExecSandboxInput, pb.ExecSandboxEvent] + cancel context.CancelFunc + sendMu sync.Mutex + inputClosed bool + inputCloseErr error + closeOnce sync.Once + closeErr error + dataCh chan []byte + done chan struct{} + errOnce sync.Once + err error + buf []byte - exitMu sync.Mutex exitCode int hasExitCode bool } @@ -196,7 +199,6 @@ func newInteractiveSession(ctx context.Context, cancel context.CancelFunc, strea stream: stream, cancel: cancel, dataCh: make(chan []byte, 64), - exitCh: make(chan int, 1), done: make(chan struct{}), } go s.readLoop(ctx) @@ -208,6 +210,7 @@ func (s *interactiveSession) setErr(err error) { } func (s *interactiveSession) readLoop(ctx context.Context) { + defer s.cancel() defer close(s.dataCh) defer close(s.done) for { @@ -215,10 +218,18 @@ func (s *interactiveSession) readLoop(ctx context.Context) { if err != nil { if err != io.EOF { s.setErr(converter.FromGRPCError(err)) + } else if ctx.Err() != nil { + s.setErr(converter.FromGRPCError(status.FromContextError(ctx.Err()).Err())) + } else if !s.hasExitCode { + s.setErr(&StatusError{Code: ErrorInternal, Message: "stream ended without exit event"}) } return } + if s.hasExitCode { + s.setErr(&StatusError{Code: ErrorInternal, Message: "received event after exit"}) + return + } chunk, code, convErr := converter.ExecChunkFromEvent(ev) if convErr != nil { s.setErr(convErr) @@ -226,15 +237,14 @@ func (s *interactiveSession) readLoop(ctx context.Context) { } // nil chunk with no error means exit event if chunk == nil { - select { - case s.exitCh <- code: - default: - } - return + s.exitCode = code + s.hasExitCode = true + continue } select { case s.dataCh <- chunk.Data: case <-ctx.Done(): + s.setErr(converter.FromGRPCError(status.FromContextError(ctx.Err()).Err())) return } } @@ -264,6 +274,9 @@ func (s *interactiveSession) Read(p []byte) (int, error) { func (s *interactiveSession) Write(p []byte) (int, error) { s.sendMu.Lock() defer s.sendMu.Unlock() + if s.inputClosed { + return 0, io.ErrClosedPipe + } err := s.stream.Send(&pb.ExecSandboxInput{ Payload: &pb.ExecSandboxInput_Stdin{Stdin: p}, }) @@ -276,6 +289,9 @@ func (s *interactiveSession) Write(p []byte) (int, error) { func (s *interactiveSession) Resize(cols, rows uint32) error { s.sendMu.Lock() defer s.sendMu.Unlock() + if s.inputClosed { + return io.ErrClosedPipe + } err := s.stream.Send(&pb.ExecSandboxInput{ Payload: &pb.ExecSandboxInput_Resize{ Resize: &pb.ExecSandboxWindowResize{ @@ -291,41 +307,33 @@ func (s *interactiveSession) Resize(cols, rows uint32) error { } func (s *interactiveSession) ExitCode() (int, error) { - s.exitMu.Lock() + <-s.done if s.hasExitCode { - code := s.exitCode - s.exitMu.Unlock() - return code, nil + return s.exitCode, s.err } - s.exitMu.Unlock() + return -1, s.err +} - select { - case code := <-s.exitCh: - s.exitMu.Lock() - s.exitCode = code - s.hasExitCode = true - s.exitMu.Unlock() - return code, nil - case <-s.done: - select { - case code := <-s.exitCh: - s.exitMu.Lock() - s.exitCode = code - s.hasExitCode = true - s.exitMu.Unlock() - return code, nil - default: - if s.err != nil { - return -1, s.err - } - return -1, &StatusError{Code: ErrorInternal, Message: "stream ended without exit event"} - } +func (s *interactiveSession) CloseWrite() error { + s.sendMu.Lock() + defer s.sendMu.Unlock() + if !s.inputClosed { + s.inputClosed = true + s.inputCloseErr = s.stream.CloseSend() } + return s.inputCloseErr +} + +func (s *interactiveSession) Cancel() error { + s.closeOnce.Do(func() { + // Cancel before taking sendMu so a blocked Write can release it. + s.cancel() + s.closeErr = s.CloseWrite() + <-s.done + }) + return s.closeErr } func (s *interactiveSession) Close() error { - s.cancel() - err := s.stream.CloseSend() - <-s.done - return err + return s.Cancel() } diff --git a/sdk/go/openshell/v1/exec_client_test.go b/sdk/go/openshell/v1/exec_client_test.go index 5489a1f95..8354fb115 100644 --- a/sdk/go/openshell/v1/exec_client_test.go +++ b/sdk/go/openshell/v1/exec_client_test.go @@ -85,6 +85,8 @@ type mockExecServer struct { interactiveErr error interactiveWaitInput bool interactiveBlock bool + interactiveWaitEOF bool + interactiveFinalErr error receivedInputs []*pb.ExecSandboxInput } @@ -116,6 +118,8 @@ func (s *mockExecServer) ExecSandboxInteractive(stream grpc.BidiStreamingServer[ s.mu.Lock() interactiveErr := s.interactiveErr interactiveBlock := s.interactiveBlock + waitEOF := s.interactiveWaitEOF + finalErr := s.interactiveFinalErr events := make([]*pb.ExecSandboxEvent, len(s.interactiveEvents)) copy(events, s.interactiveEvents) s.mu.Unlock() @@ -136,6 +140,17 @@ func (s *mockExecServer) ExecSandboxInteractive(stream grpc.BidiStreamingServer[ <-stream.Context().Done() return stream.Context().Err() } + if waitEOF { + for { + _, recvErr := stream.Recv() + if recvErr == io.EOF { + break + } + if recvErr != nil { + return recvErr + } + } + } s.mu.Lock() waitInput := s.interactiveWaitInput @@ -170,7 +185,7 @@ func (s *mockExecServer) ExecSandboxInteractive(stream grpc.BidiStreamingServer[ return err } } - return nil + return finalErr } func setupExecTest(t *testing.T, mock *mockExecServer) (*execClient, func()) { @@ -407,6 +422,86 @@ func TestExecInteractive_CloseCancelsReceiveStream(t *testing.T) { } } +func TestExecInteractive_CloseWriteDrainsOutput(t *testing.T) { + mock := newMockExecServer() + mock.interactiveWaitEOF = true + mock.interactiveEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("after EOF")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 7}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + session, err := client.Interactive(ctx, "default", "test-sandbox", []string{"cat"}, 0, 0) + require.NoError(t, err) + defer func() { _ = session.Close() }() + require.Implements(t, (*InteractiveSessionControl)(nil), session) + require.NoError(t, CloseInteractiveInput(session)) + require.NoError(t, CloseInteractiveInput(session)) + _, err = session.Write([]byte("late")) + require.ErrorIs(t, err, io.ErrClosedPipe) + require.ErrorIs(t, session.Resize(80, 24), io.ErrClosedPipe) + output, err := io.ReadAll(session) + require.NoError(t, err) + require.Equal(t, "after EOF", string(output)) + code, err := session.ExitCode() + require.NoError(t, err) + require.Equal(t, 7, code) +} + +func TestExecInteractive_RetainsExitOnTerminalFailure(t *testing.T) { + for _, finalErr := range []error{status.Error(codes.Unavailable, "connection lost"), status.Error(codes.Canceled, "cancelled")} { + t.Run(status.Code(finalErr).String(), func(t *testing.T) { + mock := newMockExecServer() + mock.interactiveEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 7}}}, + } + mock.interactiveFinalErr = finalErr + client, cleanup := setupExecTest(t, mock) + defer cleanup() + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + session, err := client.Interactive(ctx, "default", "test-sandbox", []string{"true"}, 0, 0) + require.NoError(t, err) + defer func() { _ = session.Close() }() + code, err := session.ExitCode() + require.Error(t, err) + require.Equal(t, 7, code) + var sdkErr *StatusError + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, status.Code(finalErr), status.Code(sdkErr.Cause)) + codeAgain, errAgain := session.ExitCode() + require.Equal(t, code, codeAgain) + require.Equal(t, err, errAgain) + }) + } +} + +func TestExecInteractive_RejectsEventsAfterExit(t *testing.T) { + for name, event := range map[string]*pb.ExecSandboxEvent{ + "duplicate exit": {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 1}}}, + "late output": {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("late")}}}, + } { + t.Run(name, func(t *testing.T) { + mock := newMockExecServer() + mock.interactiveEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, event, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + session, err := client.Interactive(ctx, "default", "test-sandbox", []string{"true"}, 0, 0) + require.NoError(t, err) + defer func() { _ = session.Close() }() + code, err := session.ExitCode() + require.Equal(t, 0, code) + require.ErrorContains(t, err, "after exit") + }) + } +} + func TestExecInteractive_Write(t *testing.T) { mock := newMockExecServer() mock.interactiveWaitInput = true diff --git a/sdk/go/openshell/v1/exec_control_test.go b/sdk/go/openshell/v1/exec_control_test.go new file mode 100644 index 000000000..3e2392a82 --- /dev/null +++ b/sdk/go/openshell/v1/exec_control_test.go @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "errors" + "io" + "testing" + + "github.com/stretchr/testify/require" +) + +// Deliberately implements only the original interface: downstream mocks must +// continue compiling without adding the optional lifecycle methods. +type legacyInteractiveSession struct { + closed bool + err error +} + +var _ InteractiveSession = (*legacyInteractiveSession)(nil) + +func (*legacyInteractiveSession) Read([]byte) (int, error) { return 0, io.EOF } +func (*legacyInteractiveSession) Write(p []byte) (int, error) { return len(p), nil } +func (*legacyInteractiveSession) Resize(uint32, uint32) error { return nil } +func (*legacyInteractiveSession) ExitCode() (int, error) { return 0, nil } +func (s *legacyInteractiveSession) Close() error { s.closed = true; return s.err } + +type controlledInteractiveSession struct { + legacyInteractiveSession + inputClosed bool + cancelled bool +} + +func (s *controlledInteractiveSession) CloseWrite() error { s.inputClosed = true; return s.err } +func (s *controlledInteractiveSession) Cancel() error { s.cancelled = true; return s.err } + +func TestInteractiveControlLegacyCompatibility(t *testing.T) { + s := &legacyInteractiveSession{err: errors.New("close error")} + var statusErr *StatusError + require.ErrorAs(t, CloseInteractiveInput(s), &statusErr) + require.Equal(t, ErrorUnimplemented, statusErr.Code) + require.False(t, s.closed, "unsupported input closure must not cancel the session") + require.ErrorIs(t, CancelInteractive(s), s.err) + require.True(t, s.closed) +} + +func TestInteractiveControlDispatch(t *testing.T) { + s := &controlledInteractiveSession{legacyInteractiveSession: legacyInteractiveSession{err: errors.New("control error")}} + require.Implements(t, (*InteractiveSessionControl)(nil), s) + require.ErrorIs(t, CloseInteractiveInput(s), s.err) + require.True(t, s.inputClosed) + require.ErrorIs(t, CancelInteractive(s), s.err) + require.True(t, s.cancelled) + require.False(t, s.closed, "explicit cancellation takes precedence over fallback") +} diff --git a/sdk/go/openshell/v1/integration_test.go b/sdk/go/openshell/v1/integration_test.go index d4c0beeaf..d8e70daf9 100644 --- a/sdk/go/openshell/v1/integration_test.go +++ b/sdk/go/openshell/v1/integration_test.go @@ -44,7 +44,7 @@ func TestIntegration_SandboxExecSmoke(t *testing.T) { image := os.Getenv("OPENSHELL_GO_SDK_TEST_IMAGE") if image == "" { - image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" + image = "nvcr.io/nvidia/base/ubuntu:24.04" } name := fmt.Sprintf("go-smoke-%09d", time.Now().UnixNano()%1_000_000_000) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index e56348577..99c4fac9d 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -34,7 +34,7 @@ const client = await OpenShellClient.connect({ }) const sandbox = await client.sandbox.create({ - image: 'ghcr.io/nvidia/openshell-community/sandboxes/python:latest', + image: 'registry.example.com/agents/python:latest', serviceExposures: [{ targetPort: 8080 }], }) console.log(sandbox.serviceUrls['']) @@ -126,7 +126,20 @@ for await (const event of client.sandbox.execStream(name, ['pytest', '-q'])) { } ``` -`execInteractive` is the TTY + stdin transport primitive. Drive it by consuming `output`, which yields the same chunk/exit events; `done` resolves with the exit code once the stream reaches its exit event and rejects if it ends without one. It ships raw bytes only; raw mode, signal forwarding, and SIGWINCH stay with the caller. +`execInteractive` is the TTY + stdin transport primitive. Consume `output` concurrently with awaiting `done`. The helper yields its terminal exit event and resolves `done` only after receiving an exit event and successful final gRPC status. If transport completion fails, `done` rejects and `session.exitCode` retains any observed process exit code. Duplicate exit events and output after exit are errors. + +The RPC starts immediately when the session is created, even before you consume +`output`. A background receiver observes transport errors and buffers up to 1 MiB +of output in 64 KiB chunks. Once the queue fills, receiving waits for the caller +to drain it; cancellation interrupts that wait. The bound excludes the transport's +current response frame and its own buffers. + +Call `closeInput()` (or its compatibility alias `close()`) to end stdin and resize input while continuing to receive output. Later writes and resizes throw. Call `cancel()` to abort the RPC. Both close and cancel are idempotent. The helper ships raw bytes only; raw mode, signal forwarding, and SIGWINCH stay with the caller. Input closure is not a terminal Ctrl-D keystroke. + +`execInteractive()` returns `ExecInteractiveSessionControl`, which extends the +original `ExecInteractiveSession` with `closeInput()`, `cancel()`, and `exitCode`. +Existing mocks and wrappers can keep implementing the original interface without +adding those members. Use the extended type when a wrapper exposes the new controls. ```ts const session = await client.sandbox.execInteractive(name, ['bash']) @@ -188,7 +201,7 @@ const template: SandboxWorkloadTemplate = await client.sandboxTemplates.create( metadata: { name: 'python', labels: { team: 'runtime' } }, spec: { workload: { - image: 'ghcr.io/nvidia/openshell-community/sandboxes/python:latest', + image: 'registry.example.com/agents/python:latest', environment: { FEATURE_FLAG: 'on' }, resources: { cpu: '1', memory: '512Mi' }, }, @@ -284,6 +297,7 @@ mise run sdk:ts:lint # Biome: lint + format check (read-only) mise run sdk:ts:typecheck # tsc --noEmit mise run sdk:ts:test # Vitest unit tests with an 80% line-coverage gate mise run sdk:ts:build # emit dist/ +mise run e2e:sdk:ts:exec # public interactive helper against an isolated Docker gateway ``` Formatting and linting are handled by [Biome](https://biomejs.dev) (`biome.json`): 2-space indent, single quotes, semicolons, 120-column width. Generated `src/gen/` is excluded. `sdk:ts:lint` runs in CI as part of `sdk:ts:ci`. diff --git a/sdk/typescript/e2e/interactive-exec.mjs b/sdk/typescript/e2e/interactive-exec.mjs new file mode 100644 index 000000000..eaeb94f5f --- /dev/null +++ b/sdk/typescript/e2e/interactive-exec.mjs @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Run through mise run e2e:sdk:ts:exec, which supplies an isolated gateway. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { SandboxClient } from '../dist/index.js'; + +test('public interactive helper drains output after input EOF and verifies completion', { + timeout: 300_000, +}, async () => { + assert.ok(process.env.XDG_CONFIG_HOME, 'run with the Docker gateway wrapper'); + assert.ok(process.env.OPENSHELL_GATEWAY, 'run with the Docker gateway wrapper'); + const dir = join(process.env.XDG_CONFIG_HOME, 'openshell', 'gateways', process.env.OPENSHELL_GATEWAY); + const metadata = JSON.parse(readFileSync(join(dir, 'metadata.json'), 'utf8')); + const client = await SandboxClient.connect({ + gateway: metadata.gateway_endpoint, + ...(metadata.gateway_endpoint.startsWith('https:') + ? { + caCert: readFileSync(join(dir, 'mtls', 'ca.crt')), + clientCert: readFileSync(join(dir, 'mtls', 'tls.crt')), + clientKey: readFileSync(join(dir, 'mtls', 'tls.key')), + } + : {}), + }); + const name = `ts-eof-${Date.now().toString(36)}`; + await client.create({ + name, + image: process.env.OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE ?? 'ghcr.io/nvidia/openshell-community/sandboxes/base:latest', + }); + try { + await client.waitReady(name, 180); + const session = await client.execInteractive( + name, + ['/bin/sh', '-c', 'input=$(cat); printf "stdout:%s" "$input"; printf "stderr:drained" >&2; exit 7'], + { tty: false, timeoutSecs: 30, signal: AbortSignal.timeout(45_000) }, + ); + try { + // cat cannot finish until closeInput reaches the sandbox. All command + // output therefore proves that request EOF left the response open. + session.write(Buffer.from('input-before-eof')); + session.closeInput(); + session.closeInput(); // The public control remains idempotent. + const stdout = []; + const stderr = []; + const exits = []; + for await (const event of session.output) { + assert.equal(exits.length, 0, 'exit must be the final application event'); + if ('type' in event) exits.push(event.exitCode); + else (event.stream === 'stdout' ? stdout : stderr).push(event.data); + } + assert.equal(Buffer.concat(stdout).toString(), 'stdout:input-before-eof'); + assert.equal(Buffer.concat(stderr).toString(), 'stderr:drained'); + assert.deepEqual(exits, [7]); + // A nonzero process exit is not a failed transport. done resolves only + // after the helper has verified the final gRPC status. + assert.equal(await session.done, 7); + assert.equal(session.exitCode, 7); + } finally { + session.cancel(); + } + } finally { + await client.delete(name); + } +}); diff --git a/sdk/typescript/src/client.test.ts b/sdk/typescript/src/client.test.ts index 8589c114f..a43140986 100644 --- a/sdk/typescript/src/client.test.ts +++ b/sdk/typescript/src/client.test.ts @@ -31,6 +31,7 @@ import { ServiceStatus, } from './gen/openshell_pb.js'; import { PolicySource, SettingScope } from './gen/sandbox_pb.js'; +import type { ExecInteractiveSession, ExecInteractiveSessionControl } from './index.js'; function client(impl: Partial>): SandboxClient { const transport: Transport = createRouterTransport((router) => { @@ -714,7 +715,7 @@ describe('sandbox templates', () => { metadata: { name: 'python', labels: { team: 'runtime' } }, spec: { workload: { - image: 'ghcr.io/nvidia/openshell-community/sandboxes/python:latest', + image: 'registry.example.com/agents/python:latest', environment: { FEATURE_FLAG: 'on' }, resources: { cpu: '1', memory: '512Mi', gpu: { count: 1 } }, }, @@ -963,6 +964,35 @@ describe('Pushable', () => { }); describe('execInteractive', () => { + it('preserves legacy session implementations and exposes SDK lifecycle controls', async () => { + // These are exactly the original required members, checked through the + // public package exports so downstream wrappers can keep their old types. + const legacy: ExecInteractiveSession = { + output: (async function* () { + yield { type: 'exit' as const, exitCode: 0 }; + })(), + write() {}, + resize() {}, + close() {}, + done: Promise.resolve(0), + }; + const wrap = (session: ExecInteractiveSession): ExecInteractiveSession => session; + expect(wrap(legacy)).toBe(legacy); + + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + execSandboxInteractive: async function* () { + yield { payload: { case: 'exit', value: { exitCode: 0 } } }; + }, + }); + const controlled: ExecInteractiveSessionControl = await sandbox.execInteractive('sb', ['true']); + expect(wrap(controlled)).toBe(controlled); + controlled.closeInput(); + expect(await controlled.done).toBe(0); + expect(controlled.exitCode).toBe(0); + controlled.cancel(); + }); + it('sends start first with tty/cols/rows, streams output, and resolves done', async () => { const cases: string[] = []; let started: (ScopedRequest & { tty?: boolean; cols?: number; rows?: number }) | undefined; @@ -1016,6 +1046,184 @@ describe('execInteractive', () => { }); describe('exec done settlement', () => { + it('starts the command and observes completion before output is consumed', async () => { + let started = false; + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + execSandboxInteractive: async function* () { + started = true; + yield { payload: { case: 'stdout', value: { data: enc('started') } } }; + yield { payload: { case: 'exit', value: { exitCode: 0 } } }; + }, + }); + const session = await sandbox.execInteractive('sb', ['bash']); + await expect.poll(() => started).toBe(true); + expect(await session.done).toBe(0); + const events = []; + for await (const event of session.output) events.push(event); + expect(events).toEqual([ + { stream: 'stdout', data: Buffer.from('started') }, + { type: 'exit', exitCode: 0 }, + ]); + }); + + it('observes early transport failures without output consumption', async () => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + execSandboxInteractive: () => { + throw new ConnectError('early failure', Code.Unavailable); + }, + }); + const session = await sandbox.execInteractive('sb', ['bash']); + await expect(session.done).rejects.toMatchObject({ connectCode: Code.Unavailable }); + const iterator = session.output[Symbol.asyncIterator](); + await expect(iterator.next()).rejects.toMatchObject({ connectCode: Code.Unavailable }); + }); + + it('cancels a receiver blocked by output backpressure', async () => { + let released = false; + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + execSandboxInteractive: async function* (_requests, ctx) { + ctx.signal.addEventListener('abort', () => { + released = true; + }); + // More than the SDK queue budget, in one transport event. + yield { payload: { case: 'stdout', value: { data: new Uint8Array(4 * 1024 * 1024) } } }; + yield { payload: { case: 'exit', value: { exitCode: 0 } } }; + }, + }); + const session = await sandbox.execInteractive('sb', ['bash']); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(session.exitCode).toBeUndefined(); + session.cancel(); + await expect(session.done).rejects.toMatchObject({ code: 'canceled' }); + await expect.poll(() => released).toBe(true); + await expect(session.output[Symbol.asyncIterator]().next()).rejects.toMatchObject({ code: 'canceled' }); + }); + + it('drains output larger than the queue budget without losing bytes', async () => { + const data = Buffer.alloc(2 * 1024 * 1024 + 7, 'x'); + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + execSandboxInteractive: async function* () { + yield { payload: { case: 'stdout', value: { data } } }; + yield { payload: { case: 'stderr', value: { data: enc('last') } } }; + yield { payload: { case: 'exit', value: { exitCode: 3 } } }; + }, + }); + const session = await sandbox.execInteractive('sb', ['bash']); + const chunks: Buffer[] = []; + for await (const event of session.output) { + if ('type' in event) expect(await session.done).toBe(3); + else chunks.push(event.data); + } + const actual = Buffer.concat(chunks); + const expected = Buffer.concat([data, Buffer.from('last')]); + expect(actual.length).toBe(expected.length); + // Compare bytes natively: deep equality on a multi-MiB Buffer can exhaust + // the test timeout on CI even when the stream drains promptly. + expect(actual.equals(expected)).toBe(true); + }); + + it('retains the exit code but rejects completion on a later transport error', async () => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + execSandboxInteractive: async function* () { + yield { payload: { case: 'exit', value: { exitCode: 7 } } }; + throw new ConnectError('connection lost after exit', Code.Unavailable); + }, + }); + const session = await sandbox.execInteractive('sb', ['bash']); + await expect( + (async () => { + for await (const _event of session.output) { + /* drain through trailers */ + } + })(), + ).rejects.toMatchObject({ connectCode: Code.Unavailable }); + await expect(session.done).rejects.toMatchObject({ connectCode: Code.Unavailable }); + expect(session.exitCode).toBe(7); + }); + + it.each(['stdout', 'exit'] as const)('rejects %s after an exit event', async (payload) => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + execSandboxInteractive: async function* () { + yield { payload: { case: 'exit', value: { exitCode: 0 } } }; + if (payload === 'stdout') yield { payload: { case: 'stdout', value: { data: enc('late') } } }; + else yield { payload: { case: 'exit', value: { exitCode: 1 } } }; + }, + }); + const session = await sandbox.execInteractive('sb', ['bash']); + await expect( + (async () => { + for await (const _event of session.output) { + /* drain */ + } + })(), + ).rejects.toThrow('after exit'); + await expect(session.done).rejects.toThrow('after exit'); + expect(session.exitCode).toBe(0); + }); + + it('closes input idempotently and rejects later stdin and resize', async () => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + execSandboxInteractive: async function* (requests) { + for await (const _input of requests) { + /* wait for request EOF */ + } + yield { payload: { case: 'stdout', value: { data: enc('drained') } } }; + yield { payload: { case: 'exit', value: { exitCode: 0 } } }; + }, + }); + const session = await sandbox.execInteractive('sb', ['bash']); + session.closeInput(); + session.close(); + expect(() => session.write(Buffer.from('late'))).toThrow('input is closed'); + expect(() => session.resize(80, 24)).toThrow('input is closed'); + const output = []; + for await (const event of session.output) output.push(event); + expect(output).toHaveLength(2); + expect(await session.done).toBe(0); + }); + + it('cancel settles completion even before output is consumed', async () => { + const sandbox = client({ getSandbox: () => readySandbox('sb', 'sb-id') }); + const session = await sandbox.execInteractive('sb', ['bash']); + session.cancel(); + session.cancel(); + await expect(session.done).rejects.toMatchObject({ code: 'canceled' }); + expect(() => session.write(Buffer.from('late'))).toThrow('input is closed'); + }); + + it('external cancellation settles completion before output is consumed', async () => { + const controller = new AbortController(); + const sandbox = client({ getSandbox: () => readySandbox('sb', 'sb-id') }); + const session = await sandbox.execInteractive('sb', ['bash'], { signal: controller.signal }); + controller.abort(); + await expect(session.done).rejects.toMatchObject({ code: 'canceled' }); + expect(() => session.resize(80, 24)).toThrow('input is closed'); + }); + + it('external cancellation settles completion while output is paused', async () => { + const controller = new AbortController(); + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id'), + execSandboxInteractive: async function* () { + yield { payload: { case: 'stdout', value: { data: enc('partial') } } }; + yield { payload: { case: 'exit', value: { exitCode: 0 } } }; + }, + }); + const session = await sandbox.execInteractive('sb', ['bash'], { signal: controller.signal }); + const iterator = session.output[Symbol.asyncIterator](); + await iterator.next(); + controller.abort(); + await expect(session.done).rejects.toMatchObject({ code: 'canceled' }); + await iterator.return?.(); + }); + it('resolves done even when the consumer breaks right after the exit event', async () => { const sandbox = client({ getSandbox: () => readySandbox('sb', 'sb-id'), @@ -1029,7 +1237,7 @@ describe('exec done settlement', () => { for await (const event of session.output) { if ('type' in event) break; // break on exit: the generator never resumes } - // Without settling `done` before the exit yield, this would hang forever. + // The public exit is yielded only after successful terminal status. expect(await session.done).toBe(3); }); diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index b6af52cc4..bb7b4d1de 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -302,16 +302,28 @@ export interface ExecInteractiveOptions extends SandboxWorkspaceOptions { // The transport half of an interactive exec: raw stdin/stdout/stderr plus // resize, with no terminal glue. Drive it by consuming `output`, which yields -// chunks then a terminal exit event; `done` resolves with the exit code once -// the stream reaches that exit event and rejects if the stream ends without one. +// chunks then a terminal exit event; `done` resolves only after an exit event +// and successful RPC completion. Consume output concurrently with awaiting done. export interface ExecInteractiveSession { output: AsyncIterable; write(data: Buffer): void; resize(cols: number, rows: number): void; + /** Close stdin and resize input while preserving output. */ close(): void; done: Promise; } +/** Lifecycle controls available on SDK-created sessions. The base interface + * retains its original members for existing custom sessions and wrappers. */ +export interface ExecInteractiveSessionControl extends ExecInteractiveSession { + /** Close stdin and resize input, preserving output until completion. */ + closeInput(): void; + /** Cancel the RPC and stop receiving output. */ + cancel(): void; + /** Observed process exit, retained even if final RPC completion fails. */ + readonly exitCode: number | undefined; +} + /** Cancellation for the poll-based wait helpers. */ export interface WaitOptions extends SandboxWorkspaceOptions { /** Abort the wait (and the in-flight poll RPC) early. */ @@ -736,6 +748,60 @@ export class Pushable implements AsyncIterable { } } +// Single producer/consumer queue. Closing wakes both directions, including a +// producer blocked by backpressure. Successful completion drains queued values. +class ExecOutputQueue { + private readonly values: ExecStreamEvent[] = []; + private ended = false; + private error: unknown; + private reader?: () => void; + private writer?: () => void; + + async push(value: ExecStreamEvent): Promise { + // The terminal exit carries no output bytes and must never block cleanup + // after done has already settled and its cancellation listener is removed. + while (!('type' in value) && this.values.length >= 16 && !this.ended) { + await new Promise((resolve) => { + this.writer = resolve; + }); + } + if (this.ended) throw this.error ?? new SdkError('canceled', 'exec output closed'); + this.values.push(value); + this.reader?.(); + this.reader = undefined; + } + + end(error?: unknown, discard = false): void { + if (discard) this.values.length = 0; + if (!this.ended) { + this.ended = true; + this.error = error; + } + this.reader?.(); + this.writer?.(); + this.reader = undefined; + this.writer = undefined; + } + + async *[Symbol.asyncIterator](): AsyncGenerator { + for (;;) { + const value = this.values.shift(); + if (value !== undefined) { + this.writer?.(); + this.writer = undefined; + yield value; + } else if (this.ended) { + if (this.error !== undefined) throw this.error; + return; + } else { + await new Promise((resolve) => { + this.reader = resolve; + }); + } + } + } +} + /** One response page from a list operation. */ export interface Page { readonly items: T[]; @@ -1139,7 +1205,7 @@ export class SandboxClient { name: string, command: string[], options?: ExecInteractiveOptions | null, - ): Promise { + ): Promise { try { await this.get(name, { workspace: options?.workspace, ...(options?.signal ? { signal: options.signal } : {}) }); } catch (e) { @@ -1165,7 +1231,19 @@ export class SandboxClient { }, }); - const stream = this.grpc.execSandboxInteractive(input, { signal: options?.signal }); + const controller = new AbortController(); + const signal = options?.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal; + const grpc = this.grpc; + const queue = new ExecOutputQueue(); + let inputClosed = false; + let exitCode: number | undefined; + const closeInput = (): void => { + inputClosed = true; + input.end(); + }; + const assertInputOpen = (): void => { + if (inputClosed || signal.aborted) throw new SdkError('io', 'exec input is closed'); + }; let resolveDone!: (code: number) => void; let rejectDone!: (err: unknown) => void; const done = new Promise((resolve, reject) => { @@ -1176,72 +1254,113 @@ export class SandboxClient { // keeps an unobserved rejection from surfacing as an unhandledRejection; // real awaiters still receive it through their own handler. void done.catch(() => {}); - // Settle exactly once. The exit code wins; error/abandonment only apply - // when no exit was observed. + // The process exit and the terminal transport status are separate outcomes. let settled = false; const settleExit = (code: number): void => { if (settled) return; settled = true; + signal.removeEventListener('abort', onAbort); resolveDone(code); }; const settleError = (err: unknown): void => { if (settled) return; settled = true; + signal.removeEventListener('abort', onAbort); rejectDone(err); }; - async function* output(): AsyncGenerator { - let sawExit = false; + const onAbort = (): void => { + closeInput(); + const error = new SdkError('canceled', 'exec cancelled'); + queue.end(error, true); + settleError(error); + }; + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + + async function receive(): Promise { try { + if (signal.aborted) throw new SdkError('canceled', 'exec cancelled'); + // Start and observe the transport immediately, independently of output + // consumption. Backpressure bounds the queue to 16 chunks of 64 KiB. + const stream = grpc.execSandboxInteractive(input, { signal }); for await (const event of stream) { + if (signal.aborted) throw new SdkError('canceled', 'exec cancelled'); + if (exitCode !== undefined) { + throw new SdkError('rpc', 'ExecSandboxInteractive received an event after exit'); + } switch (event.payload.case) { case 'stdout': - yield { - stream: 'stdout', - data: Buffer.from(event.payload.value.data), - }; - break; case 'stderr': - yield { - stream: 'stderr', - data: Buffer.from(event.payload.value.data), - }; + for (let offset = 0; offset < event.payload.value.data.length; offset += 64 * 1024) { + await queue.push({ + stream: event.payload.case, + data: Buffer.from(event.payload.value.data.subarray(offset, offset + 64 * 1024)), + }); + } break; case 'exit': - sawExit = true; - // Settle `done` before yielding: a consumer that breaks on the - // exit event abandons the generator at the yield, so anything - // after it would never run. - settleExit(event.payload.value.exitCode); - yield { type: 'exit', exitCode: event.payload.value.exitCode }; + exitCode = event.payload.value.exitCode; + closeInput(); break; + default: + throw new SdkError('rpc', 'ExecSandboxInteractive received an empty or unknown event'); } } - if (!sawExit) { + if (exitCode === undefined) { throw new SdkError('rpc', 'ExecSandboxInteractive stream ended without an exit event'); } + if (signal.aborted) throw new SdkError('canceled', 'exec cancelled before completion'); + // Delay the public exit event until trailers have been consumed. A + // caller can still break on exit without losing the terminal status. + settleExit(exitCode); + await queue.push({ type: 'exit', exitCode }); + queue.end(); } catch (e) { const err = e instanceof SdkError ? e : fromConnect(e); settleError(err); - throw err; + queue.end(err); } finally { - input.end(); - // Consumer abandoned the stream before an exit event (early break or - // return): settle `done` so it can never hang. - settleError(new SdkError('rpc', 'exec output abandoned before exit')); + closeInput(); + controller.abort(); + } + } + + // receive catches transport failures even when nobody consumes output/done. + const receiving = receive(); + async function* output(): AsyncGenerator { + try { + yield* queue; + } finally { + const error = new SdkError('rpc', 'exec output abandoned before completion'); + settleError(error); + queue.end(error, true); + closeInput(); + controller.abort(); + await receiving; } } return { output: output(), write(data: Buffer): void { + assertInputOpen(); input.push({ payload: { case: 'stdin', value: new Uint8Array(data) } }); }, resize(cols: number, rows: number): void { + assertInputOpen(); input.push({ payload: { case: 'resize', value: { cols, rows } } }); }, - close(): void { - input.end(); + closeInput, + close: closeInput, + cancel(): void { + closeInput(); + queue.end(new SdkError('canceled', 'exec cancelled'), true); + controller.abort(); + settleError(new SdkError('canceled', 'exec cancelled')); + }, + get exitCode(): number | undefined { + return exitCode; }, done, }; diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 3902f9135..e0802fc8f 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -12,6 +12,7 @@ export type { ExecExitEvent, ExecInteractiveOptions, ExecInteractiveSession, + ExecInteractiveSessionControl, ExecOptions, ExecResult, ExecStreamChunk, diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index 89e6223ac..37e1438cb 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -171,7 +171,7 @@ Key sections to reference: When middleware is requested, also read the published [supervisor middleware guide](https://docs.nvidia.com/openshell/latest/extensibility/supervisor-middleware.md). -For enforcement concepts and the shipped baseline, read [sandbox policies](https://docs.nvidia.com/openshell/latest/sandboxes/policies.md) and the [default policy reference](https://docs.nvidia.com/openshell/latest/reference/default-policy.md). The default policy is baked into the community base image (`ghcr.io/nvidia/openshell-community/sandboxes/base:latest`). +For enforcement concepts and the shipped baseline, read [sandbox policies](https://docs.nvidia.com/openshell/latest/sandboxes/policies.md) and the [default policy reference](https://docs.nvidia.com/openshell/latest/reference/default-policy.md). The default policy is built into the OpenShell runtime and applies when no explicit policy is supplied. Validate the intended provider combination as well as the authored policy. An image endpoint can become credentialed after provider composition and block @@ -642,5 +642,5 @@ private_services: - [Sandbox policies](https://docs.nvidia.com/openshell/latest/sandboxes/policies.md) - [Default policy](https://docs.nvidia.com/openshell/latest/reference/default-policy.md) - [Supervisor middleware](https://docs.nvidia.com/openshell/latest/extensibility/supervisor-middleware.md) -- Default policy: baked into the community base image (`ghcr.io/nvidia/openshell-community/sandboxes/base:latest`) +- Default policy: built into the OpenShell runtime - For translation examples from real API docs, see [examples.md](examples.md) diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index 8b44050dc..df990fb91 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -97,8 +97,8 @@ exist but a profile with that ID is available, the CLI can create it from local credentials: ```bash -openshell sandbox create --provider claude-code -- claude -openshell sandbox create --provider codex -- codex +openshell sandbox create --from registry.example.com/your-org/claude-agent:latest --provider claude-code -- claude +openshell sandbox create --from registry.example.com/your-org/codex-agent:latest --provider codex -- codex ``` The agent will be prompted interactively if credentials are missing. @@ -292,7 +292,7 @@ image, environment, sizing, or driver-specific configuration: ```bash openshell sandbox template create gpu-kata \ - --image ghcr.io/nvidia/openshell-community/sandboxes/python:latest \ + --image registry.example.com/agents/python:latest \ --cpu 2 \ --memory 4Gi \ --gpu 1 \ @@ -308,7 +308,7 @@ creates. Put driver config on a template only when it should be reused. ```bash openshell sandbox template create gpu-kata \ - --image ghcr.io/nvidia/openshell-community/sandboxes/python:latest \ + --image registry.example.com/agents/python:latest \ --cpu 2 \ --memory 4Gi \ --gpu 1 \ @@ -501,7 +501,7 @@ Create sandbox with initial policy ### Step 1: Create sandbox with initial policy ```bash -openshell sandbox create --name dev --policy ./initial-policy.yaml -- claude +openshell sandbox create --name dev --from registry.example.com/your-org/claude-agent:latest --policy ./initial-policy.yaml -- claude ``` Sandboxes stay alive by default for iteration. Add `--no-keep` only when the sandbox should be deleted automatically after the initial session. @@ -634,15 +634,14 @@ docker build -t my-app:latest . openshell sandbox create --from my-app:latest --name my-app ``` -The `--from` flag accepts an existing full image reference such as `myregistry.com/img:tag`, or a community sandbox name such as `ollama`. Build local Dockerfiles first with the same container engine as the local gateway, then pass the image tag. +The `--from` flag accepts an explicit OCI image reference such as `myregistry.com/img:tag`. It does not expand catalog aliases. Build local Dockerfiles first with the same container engine as the local gateway, then pass the image tag. -Use `docker build -t my-app:latest` for Docker gateways. For Podman gateways, use `podman build -t localhost/my-app:latest` and pass `localhost/my-app:latest` to `--from`. For remote gateways, push the image to a registry reachable by the gateway. Bare community names resolve under `ghcr.io/nvidia/openshell-community/sandboxes` unless `OPENSHELL_COMMUNITY_REGISTRY` overrides the prefix. +Use `docker build -t my-app:latest` for Docker gateways. For Podman gateways, use `podman build -t localhost/my-app:latest` and pass `localhost/my-app:latest` to `--from`. For remote gateways, push the image to a registry reachable by the gateway. For Docker and Podman gateways, custom images should declare a non-root OCI -`USER`. Each explicit `process.run_as_user` or `process.run_as_group` policy +`USER`. Images without one run as numeric UID and GID `1000`. Each explicit `process.run_as_user` or `process.run_as_group` policy field wins independently; omitted fields fall back to the image declaration. -An image with no `USER` fails before readiness unless policy supplies both -fields. Explicit numeric fields may use any UID/GID from `1` through +Explicit numeric fields may use any UID/GID from `1` through `4294967294`; `0` is root and `4294967295` is the invalid identity sentinel. Warn users that low IDs can inherit permissions from matching accounts, image files, mounted volumes, or devices. diff --git a/tasks/scripts/e2e-build-workload.sh b/tasks/scripts/e2e-build-workload.sh new file mode 100644 index 000000000..a7679d967 --- /dev/null +++ b/tasks/scripts/e2e-build-workload.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=tasks/scripts/container-engine.sh +source "${ROOT}/tasks/scripts/container-engine.sh" +ce_build --load --file "${ROOT}/e2e/python/Dockerfile.workload" \ + --build-arg "PYTHON_VERSION=$(cat "${ROOT}/.python-version")" \ + --tag openshell/e2e-python:dev \ + "${ROOT}/e2e/python" diff --git a/tasks/scripts/e2e-gpu-build-images.sh b/tasks/scripts/e2e-gpu-build-images.sh index efe3d8378..d7bebcbc8 100644 --- a/tasks/scripts/e2e-gpu-build-images.sh +++ b/tasks/scripts/e2e-gpu-build-images.sh @@ -12,7 +12,7 @@ source "${SCRIPT_DIR}/container-engine.sh" IMAGES_ROOT="${ROOT}/e2e/gpu/images" BUILD_DIR="${IMAGES_ROOT}/.build" -BASE_IMAGE="${OPENSHELL_SANDBOX_BASE_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/base:latest}" +BASE_IMAGE="${OPENSHELL_SANDBOX_BASE_IMAGE:-nvcr.io/nvidia/base/ubuntu:24.04}" CUDA_BUILD_IMAGE="${CUDA_BUILD_IMAGE:-nvcr.io/nvidia/cuda:12.8.1-base-ubuntu22.04}" CUDA_SAMPLES_REPO="${CUDA_SAMPLES_REPO:-https://github.com/NVIDIA/cuda-samples}" CUDA_SAMPLES_REF="${CUDA_SAMPLES_REF:-v12.8}" diff --git a/tasks/scripts/gateway-docker.sh b/tasks/scripts/gateway-docker.sh index a98ff3ea2..b85c9a663 100644 --- a/tasks/scripts/gateway-docker.sh +++ b/tasks/scripts/gateway-docker.sh @@ -34,7 +34,7 @@ PORT="${OPENSHELL_SERVER_PORT:-18080}" GATEWAY_NAME="${OPENSHELL_DOCKER_GATEWAY_NAME:-docker-dev}" STATE_DIR="${OPENSHELL_DOCKER_GATEWAY_STATE_DIR:-${ROOT}/.cache/gateway-docker}" SANDBOX_NAMESPACE="${OPENSHELL_SANDBOX_NAMESPACE:-docker-dev}" -SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/base:latest}" +SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-nvcr.io/nvidia/base/ubuntu:24.04}" SUPERVISOR_IMAGE="${OPENSHELL_SUPERVISOR_IMAGE:-openshell/supervisor:dev}" SANDBOX_RUNTIME_IMAGE="${OPENSHELL_SANDBOX_RUNTIME_IMAGE:-openshell/sandbox:dev}" SANDBOX_IMAGE_PULL_POLICY="$(normalize_image_pull_policy "${OPENSHELL_SANDBOX_IMAGE_PULL_POLICY:-if_not_present}")" diff --git a/tasks/scripts/gateway-podman.sh b/tasks/scripts/gateway-podman.sh index 745dcd542..d2b5397fb 100644 --- a/tasks/scripts/gateway-podman.sh +++ b/tasks/scripts/gateway-podman.sh @@ -31,7 +31,7 @@ PORT="${OPENSHELL_SERVER_PORT:-18080}" GATEWAY_NAME="${OPENSHELL_PODMAN_GATEWAY_NAME:-podman-dev}" STATE_DIR="${OPENSHELL_PODMAN_GATEWAY_STATE_DIR:-${OPENSHELL_GATEWAY_STATE_DIR:-${ROOT}/.cache/gateway-podman}}" SANDBOX_NAMESPACE="${OPENSHELL_SANDBOX_NAMESPACE:-podman-dev}" -SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/base:latest}" +SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-nvcr.io/nvidia/base/ubuntu:24.04}" SANDBOX_IMAGE_PULL_POLICY="$(normalize_image_pull_policy "${OPENSHELL_SANDBOX_IMAGE_PULL_POLICY:-if_not_present}")" GRPC_ENDPOINT="${OPENSHELL_GRPC_ENDPOINT:-}" LOG_LEVEL="${OPENSHELL_LOG_LEVEL:-info}" diff --git a/tasks/scripts/gateway.sh b/tasks/scripts/gateway.sh index 34bc143fb..8df3dace9 100644 --- a/tasks/scripts/gateway.sh +++ b/tasks/scripts/gateway.sh @@ -207,7 +207,7 @@ PORT="${OPENSHELL_SERVER_PORT:-8080}" GATEWAY_NAME="${OPENSHELL_GATEWAY_NAME:-${DRIVER}-dev}" STATE_DIR="${OPENSHELL_GATEWAY_STATE_DIR:-${ROOT}/.cache/gateway-${DRIVER}}" SANDBOX_NAMESPACE="${OPENSHELL_SANDBOX_NAMESPACE:-${DRIVER}-dev}" -SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-ghcr.io/nvidia/openshell-community/sandboxes/base:latest}" +SANDBOX_IMAGE="${OPENSHELL_SANDBOX_IMAGE:-nvcr.io/nvidia/base/ubuntu:24.04}" SANDBOX_IMAGE_PULL_POLICY="$(normalize_image_pull_policy "${OPENSHELL_SANDBOX_IMAGE_PULL_POLICY:-if_not_present}")" GRPC_ENDPOINT="${OPENSHELL_GRPC_ENDPOINT:-}" LOG_LEVEL="${OPENSHELL_LOG_LEVEL:-info}" diff --git a/tasks/scripts/helm-k3s-local.sh b/tasks/scripts/helm-k3s-local.sh index dc8adb9bd..e24893e7d 100755 --- a/tasks/scripts/helm-k3s-local.sh +++ b/tasks/scripts/helm-k3s-local.sh @@ -27,9 +27,9 @@ K3D_CLUSTER_NAME_MAX=32 # Host port forwarded to port 80 via the k3d load balancer. # Used by Envoy Gateway's LoadBalancer service (values-gateway.yaml). HOST_LB_PORT="${HELM_K3S_LB_HOST_PORT:-8080}" -# Preload the default community sandbox image so the first sandbox create does +# Preload the default sandbox image so the first sandbox create does # not pay the full registry pull cost inside the cluster. -DEFAULT_SANDBOX_PRELOAD_IMAGE="ghcr.io/nvidia/openshell-community/sandboxes/base:latest" +DEFAULT_SANDBOX_PRELOAD_IMAGE="nvcr.io/nvidia/base/ubuntu:24.04" PRELOAD_SANDBOX_IMAGE="${HELM_K3S_PRELOAD_SANDBOX_IMAGE-${DEFAULT_SANDBOX_PRELOAD_IMAGE}}" # Upstream agent-sandbox release pinned for both CRDs/controller and extensions. diff --git a/tasks/test.toml b/tasks/test.toml index 317f676c1..74166db44 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -102,6 +102,10 @@ description = "Run Rust CLI e2e tests against a Docker-backed gateway" depends = ["e2e:conformance:build"] run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-docker.sh" +["e2e:workload:build"] +description = "Build the Docker E2E workload fixture" +run = "CONTAINER_ENGINE=docker bash tasks/scripts/e2e-build-workload.sh" + ["e2e:conformance:build"] description = "Build the standalone CLI conformance binary" run = "if [ -z \"${OPENSHELL_CONFORMANCE_BIN:-}\" ]; then cargo build -p openshell-conformance-cli; fi" @@ -118,6 +122,7 @@ run = [ ["e2e:websocket-conformance"] description = "Run focused WebSocket conformance e2e tests against a Docker-backed gateway" run = [ + "CONTAINER_ENGINE=docker bash tasks/scripts/e2e-build-workload.sh", "e2e/with-docker-gateway.sh cargo test --manifest-path e2e/rust/Cargo.toml --features e2e-docker --test websocket_conformance", ] @@ -133,7 +138,10 @@ depends = ["e2e:mcp"] description = "Run Python e2e tests against a Docker-backed gateway (E2E_PARALLEL=N or 'auto'; default 5)" depends = ["python:proto"] env = { UV_NO_SYNC = "1", PYTHONPATH = "python" } -run = "e2e/with-docker-gateway.sh uv run pytest -o python_files='test_*.py *_test.py' -m 'not gpu' -n ${E2E_PARALLEL:-5} e2e/python" +run = [ + "CONTAINER_ENGINE=docker bash tasks/scripts/e2e-build-workload.sh", + "OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE=openshell/e2e-python:dev OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE_PULL_POLICY=never e2e/with-docker-gateway.sh uv run pytest -o python_files='test_*.py *_test.py' -m 'not gpu' -n ${E2E_PARALLEL:-5} e2e/python", +] ["e2e:podman"] description = "Run Rust CLI e2e tests against a Podman-backed gateway" diff --git a/tasks/typescript.toml b/tasks/typescript.toml index 57277e05b..ed07379c6 100644 --- a/tasks/typescript.toml +++ b/tasks/typescript.toml @@ -59,6 +59,11 @@ depends = ["sdk:ts:proto"] dir = "sdk/typescript" run = "npm test" +["e2e:sdk:ts:exec"] +description = "Test public TypeScript interactive exec against a Docker-backed gateway" +depends = ["sdk:ts:build"] +run = "e2e/with-docker-gateway.sh node --test sdk/typescript/e2e/interactive-exec.mjs" + ["sdk:ts:ci"] description = "TypeScript SDK checks (proto lint, Biome lint, codegen, typecheck, test, build)" depends = [ diff --git a/tests/ansible/playbooks/shell.yaml b/tests/ansible/playbooks/shell.yaml new file mode 100644 index 000000000..cdacda8bb --- /dev/null +++ b/tests/ansible/playbooks/shell.yaml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +- name: Prepare an interactive tmachine shell + hosts: all + gather_facts: false + tasks: + - name: Wait for SSH + ansible.builtin.wait_for_connection: + + - name: Set the tmachine SSH password + become: true + ansible.builtin.command: + argv: [chpasswd] + stdin: tmachine:tmachine + no_log: true + changed_when: false diff --git a/tests/ansible/roles/openshell_gateway/templates/gateway-docker.toml.j2 b/tests/ansible/roles/openshell_gateway/templates/gateway-docker.toml.j2 index 320d2979c..e1992c60e 100644 --- a/tests/ansible/roles/openshell_gateway/templates/gateway-docker.toml.j2 +++ b/tests/ansible/roles/openshell_gateway/templates/gateway-docker.toml.j2 @@ -4,7 +4,6 @@ {% block driver %} [openshell.drivers.docker] socket_path = "{{ openshell_runtime_socket }}" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" image_pull_policy = "if_not_present" sandbox_label = "tmachine" grpc_endpoint = "http://127.0.0.1:17670" diff --git a/tests/ansible/roles/openshell_gateway/templates/gateway-podman.toml.j2 b/tests/ansible/roles/openshell_gateway/templates/gateway-podman.toml.j2 index 61658c1af..08f9ca4b1 100644 --- a/tests/ansible/roles/openshell_gateway/templates/gateway-podman.toml.j2 +++ b/tests/ansible/roles/openshell_gateway/templates/gateway-podman.toml.j2 @@ -4,7 +4,6 @@ {% block driver %} [openshell.drivers.podman] socket_path = "{{ openshell_runtime_socket }}" -default_image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" image_pull_policy = "if_not_present" sandbox_runtime_image = "docker.io/openshell/sandbox:tmachine" supervisor_image = "docker.io/openshell/supervisor:tmachine" diff --git a/tests/config.nix b/tests/config.nix index b542af661..c8d80e6eb 100644 --- a/tests/config.nix +++ b/tests/config.nix @@ -70,6 +70,12 @@ let ]; installers = [ + { + name = "none"; + use_galaxy = false; + playbooks = [ ]; + inputs = { }; + } { name = "binaries"; use_galaxy = false; @@ -99,6 +105,12 @@ let ]; testsuites = [ + { + name = "shell"; + playbooks = [ "ansible/playbooks/shell.yaml" ]; + inputs = { }; + interactive = true; + } { name = "conformance"; playbooks = [ "ansible/playbooks/conformance/cli.yaml" ]; diff --git a/tests/tmachine/src/config.rs b/tests/tmachine/src/config.rs index f9c1657b2..bc19cbbb1 100644 --- a/tests/tmachine/src/config.rs +++ b/tests/tmachine/src/config.rs @@ -47,6 +47,8 @@ pub struct Testsuite { pub name: String, pub playbooks: Vec, pub inputs: BTreeMap, + #[serde(default)] + pub interactive: bool, } impl Config { diff --git a/tests/tmachine/src/qemu/test.rs b/tests/tmachine/src/qemu/test.rs index c103477ee..ac8a8bdfc 100644 --- a/tests/tmachine/src/qemu/test.rs +++ b/tests/tmachine/src/qemu/test.rs @@ -1,10 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use std::process::Stdio; + +use anyhow::{Context, Result}; use tempfile::tempdir; +use tokio::process::Command; use crate::config::{Environment, Installer, Machine, Testsuite}; -use anyhow::Result; use super::img::QemuImage; use super::install::install; @@ -25,6 +28,37 @@ pub async fn test( run_playbooks(&testsuite.playbooks, &testsuite.inputs).await?; + if testsuite.interactive { + println!("Opening an SSH shell in the tmachine VM."); + let ssh_status = Command::new("sshpass") + .env("SSHPASS", "tmachine") + .args([ + "-e", + "ssh", + "-tt", + "-p", + "2222", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "LogLevel=ERROR", + "tmachine@127.0.0.1", + ]) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .await + .context("open SSH shell in tmachine VM")?; + + vm.shutdown().await; + vm.wait().await; + anyhow::ensure!(ssh_status.success(), "SSH shell exited with {ssh_status}"); + return Ok(()); + } + vm.shutdown().await; vm.wait().await; Ok(())