diff --git a/.gitattributes b/.gitattributes index 79e7af9c9..78b568576 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,4 +7,4 @@ python/openshell/_proto/*_pb2_grpc.py linguist-generated python/openshell/_proto/*_pb2.pyi linguist-generated # Generated Rust protobuf code (excludes hand-written mod.rs) -crates/navigator-core/src/proto/navigator.*.rs linguist-generated +crates/openshell-core/src/proto/openshell.*.rs linguist-generated diff --git a/.github/workflows/ci-image.yml b/.github/workflows/ci-image.yml index 853a72fc3..2f7737d96 100644 --- a/.github/workflows/ci-image.yml +++ b/.github/workflows/ci-image.yml @@ -36,10 +36,12 @@ jobs: uses: ./.github/actions/setup-buildx - name: Build and push CI image + env: + MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | docker buildx build \ --platform linux/amd64,linux/arm64 \ - --build-arg MISE_GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} \ + --secret id=MISE_GITHUB_TOKEN,env=MISE_GITHUB_TOKEN \ --push \ -t ${{ env.CI_IMAGE }}:${{ github.sha }} \ -t ${{ env.CI_IMAGE }}:latest \ diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index c8515e923..ad5c07064 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -96,7 +96,6 @@ jobs: timeout-minutes: 120 outputs: wheel_version: ${{ needs.compute-versions.outputs.python_version }} - s3_prefix: ${{ steps.upload.outputs.s3_prefix }} container: image: ghcr.io/nvidia/openshell/ci:latest credentials: @@ -109,10 +108,6 @@ jobs: MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SCCACHE_MEMCACHED_ENDPOINT: ${{ vars.SCCACHE_MEMCACHED_ENDPOINT }} OPENSHELL_IMAGE_TAG: dev - NAV_PYPI_S3_BUCKET: navigator-pypi-artifacts # TODO: rename bucket to openshell-pypi-artifacts - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: us-west-2 steps: - uses: actions/checkout@v4 with: @@ -137,16 +132,6 @@ jobs: OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" mise run python:build:macos ls -la target/wheels/*.whl - - name: Upload wheels to S3 - id: upload - run: | - set -euo pipefail - WHEEL_VERSION="${{ needs.compute-versions.outputs.python_version }}" - S3_PREFIX="openshell/${WHEEL_VERSION}" - aws s3 cp target/wheels/ "s3://${NAV_PYPI_S3_BUCKET}/${S3_PREFIX}/" --recursive --exclude "*" --include "*.whl" - aws s3 ls "s3://${NAV_PYPI_S3_BUCKET}/${S3_PREFIX}/" - echo "s3_prefix=${S3_PREFIX}" >> "$GITHUB_OUTPUT" - - name: Upload wheel artifacts uses: actions/upload-artifact@v4 with: @@ -154,79 +139,6 @@ jobs: path: target/wheels/*.whl retention-days: 5 - publish-python: - name: Publish Python - needs: [build-python-wheels] - runs-on: [self-hosted, nv] - timeout-minutes: 10 - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NAV_PYPI_S3_BUCKET: navigator-pypi-artifacts # TODO: rename bucket to openshell-pypi-artifacts - NAV_PYPI_REPOSITORY_URL: https://urm.nvidia.com/artifactory/api/pypi/nv-shared-pypi-local - NAV_PYPI_USERNAME: ${{ secrets.NAV_PYPI_USERNAME }} - NAV_PYPI_PASSWORD: ${{ secrets.NAV_PYPI_PASSWORD }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: us-west-2 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - - name: Install publish dependencies - run: | - set -euo pipefail - python -m pip install --upgrade pip uv - - if ! command -v aws >/dev/null 2>&1; then - ARCH="$(uname -m)" - case "$ARCH" in - x86_64|amd64) AWSCLI_ARCH="x86_64" ;; - aarch64|arm64) AWSCLI_ARCH="aarch64" ;; - *) - echo "Unsupported architecture for AWS CLI installer: $ARCH" >&2 - exit 1 - ;; - esac - - rm -rf aws awscliv2.zip - curl --fail --silent --show-error --location \ - "https://awscli.amazonaws.com/awscli-exe-linux-${AWSCLI_ARCH}.zip" \ - --output awscliv2.zip - unzip -q awscliv2.zip - ./aws/install --install-dir "$HOME/.local/aws-cli" --bin-dir "$HOME/.local/bin" --update - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - export PATH="$HOME/.local/bin:$PATH" - fi - - aws --version - uv --version - - - name: List and download versioned wheels from S3 - run: | - set -euo pipefail - WHEEL_VERSION="${{ needs.build-python-wheels.outputs.wheel_version }}" - S3_PREFIX="${{ needs.build-python-wheels.outputs.s3_prefix }}" - OBJECT_COUNT=$(aws s3api list-objects-v2 --bucket "$NAV_PYPI_S3_BUCKET" --prefix "${S3_PREFIX}/" --query "length(Contents)" --output text) - if [ "$OBJECT_COUNT" = "None" ] || [ "$OBJECT_COUNT" = "0" ]; then - echo "No wheel artifacts found for ${WHEEL_VERSION} at s3://${NAV_PYPI_S3_BUCKET}/${S3_PREFIX}/" >&2 - exit 1 - fi - aws s3api list-objects-v2 --bucket "$NAV_PYPI_S3_BUCKET" --prefix "${S3_PREFIX}/" --query "Contents[].Key" --output text - mkdir -p target/wheels - aws s3 cp "s3://${NAV_PYPI_S3_BUCKET}/${S3_PREFIX}/" target/wheels/ --recursive --exclude "*" --include "*.whl" - ls -la target/wheels/*.whl - - - name: Publish wheels to Artifactory - run: | - set -euo pipefail - WHEEL_VERSION="${{ needs.build-python-wheels.outputs.wheel_version }}" - uv run python tasks/scripts/release.py python-publish --version "$WHEEL_VERSION" - # --------------------------------------------------------------------------- # Build CLI binaries (Linux musl — static, native on each arch) # --------------------------------------------------------------------------- diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 38f29fcb3..e36de607b 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -116,7 +116,6 @@ jobs: timeout-minutes: 120 outputs: wheel_version: ${{ needs.compute-versions.outputs.python_version }} - s3_prefix: ${{ steps.upload.outputs.s3_prefix }} container: image: ghcr.io/nvidia/openshell/ci:latest credentials: @@ -129,10 +128,6 @@ jobs: MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SCCACHE_MEMCACHED_ENDPOINT: ${{ vars.SCCACHE_MEMCACHED_ENDPOINT }} OPENSHELL_IMAGE_TAG: ${{ needs.compute-versions.outputs.semver }} - NAV_PYPI_S3_BUCKET: navigator-pypi-artifacts # TODO: rename bucket to openshell-pypi-artifacts - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: us-west-2 steps: - uses: actions/checkout@v4 with: @@ -158,16 +153,6 @@ jobs: OPENSHELL_CARGO_VERSION="${{ needs.compute-versions.outputs.cargo_version }}" mise run python:build:macos ls -la target/wheels/*.whl - - name: Upload wheels to S3 - id: upload - run: | - set -euo pipefail - WHEEL_VERSION="${{ needs.compute-versions.outputs.python_version }}" - S3_PREFIX="openshell/${WHEEL_VERSION}" - aws s3 cp target/wheels/ "s3://${NAV_PYPI_S3_BUCKET}/${S3_PREFIX}/" --recursive --exclude "*" --include "*.whl" - aws s3 ls "s3://${NAV_PYPI_S3_BUCKET}/${S3_PREFIX}/" - echo "s3_prefix=${S3_PREFIX}" >> "$GITHUB_OUTPUT" - - name: Upload wheel artifacts uses: actions/upload-artifact@v4 with: @@ -175,80 +160,6 @@ jobs: path: target/wheels/*.whl retention-days: 5 - publish-python: - name: Publish Python - needs: [build-python-wheels, e2e] - runs-on: [self-hosted, nv] - timeout-minutes: 10 - env: - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NAV_PYPI_S3_BUCKET: navigator-pypi-artifacts # TODO: rename bucket to openshell-pypi-artifacts - NAV_PYPI_REPOSITORY_URL: https://urm.nvidia.com/artifactory/api/pypi/nv-shared-pypi-local - NAV_PYPI_USERNAME: ${{ secrets.NAV_PYPI_USERNAME }} - NAV_PYPI_PASSWORD: ${{ secrets.NAV_PYPI_PASSWORD }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: us-west-2 - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ inputs.tag || github.ref }} - fetch-depth: 0 - - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - - name: Install publish dependencies - run: | - set -euo pipefail - python -m pip install --upgrade pip uv - - if ! command -v aws >/dev/null 2>&1; then - ARCH="$(uname -m)" - case "$ARCH" in - x86_64|amd64) AWSCLI_ARCH="x86_64" ;; - aarch64|arm64) AWSCLI_ARCH="aarch64" ;; - *) - echo "Unsupported architecture for AWS CLI installer: $ARCH" >&2 - exit 1 - ;; - esac - - rm -rf aws awscliv2.zip - curl --fail --silent --show-error --location \ - "https://awscli.amazonaws.com/awscli-exe-linux-${AWSCLI_ARCH}.zip" \ - --output awscliv2.zip - unzip -q awscliv2.zip - ./aws/install --install-dir "$HOME/.local/aws-cli" --bin-dir "$HOME/.local/bin" --update - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - export PATH="$HOME/.local/bin:$PATH" - fi - - aws --version - uv --version - - - name: List and download versioned wheels from S3 - run: | - set -euo pipefail - WHEEL_VERSION="${{ needs.build-python-wheels.outputs.wheel_version }}" - S3_PREFIX="${{ needs.build-python-wheels.outputs.s3_prefix }}" - OBJECT_COUNT=$(aws s3api list-objects-v2 --bucket "$NAV_PYPI_S3_BUCKET" --prefix "${S3_PREFIX}/" --query "length(Contents)" --output text) - if [ "$OBJECT_COUNT" = "None" ] || [ "$OBJECT_COUNT" = "0" ]; then - echo "No wheel artifacts found for ${WHEEL_VERSION} at s3://${NAV_PYPI_S3_BUCKET}/${S3_PREFIX}/" >&2 - exit 1 - fi - aws s3api list-objects-v2 --bucket "$NAV_PYPI_S3_BUCKET" --prefix "${S3_PREFIX}/" --query "Contents[].Key" --output text - mkdir -p target/wheels - aws s3 cp "s3://${NAV_PYPI_S3_BUCKET}/${S3_PREFIX}/" target/wheels/ --recursive --exclude "*" --include "*.whl" - ls -la target/wheels/*.whl - - - name: Publish wheels to Artifactory - run: | - set -euo pipefail - WHEEL_VERSION="${{ needs.build-python-wheels.outputs.wheel_version }}" - uv run python tasks/scripts/release.py python-publish --version "$WHEEL_VERSION" - # --------------------------------------------------------------------------- # Build CLI binaries (Linux musl — static, native on each arch) # --------------------------------------------------------------------------- @@ -413,7 +324,7 @@ jobs: # --------------------------------------------------------------------------- release: name: Release - needs: [compute-versions, build-cli-linux, build-cli-macos, build-python-wheels, publish-python, tag-ghcr-release] + needs: [compute-versions, build-cli-linux, build-cli-macos, build-python-wheels, tag-ghcr-release] runs-on: build-amd64 timeout-minutes: 10 steps: diff --git a/Cargo.lock b/Cargo.lock index 3978e1836..3d01356a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2810,6 +2810,7 @@ dependencies = [ "bytes", "futures", "miette", + "openshell-core", "rcgen", "serde", "serde_json", @@ -2874,6 +2875,7 @@ dependencies = [ "protobuf-src", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", "tonic", "tonic-build", @@ -2981,12 +2983,14 @@ dependencies = [ "miette", "openshell-core", "openshell-policy", + "openshell-router", "petname", "pin-project-lite", "prost", "prost-types", "rand 0.9.2", "rcgen", + "reqwest", "russh", "rustls", "rustls-pemfile", @@ -3006,6 +3010,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "wiremock", ] [[package]] diff --git a/architecture/inference-routing.md b/architecture/inference-routing.md index b8ce8a805..0d3a95afb 100644 --- a/architecture/inference-routing.md +++ b/architecture/inference-routing.md @@ -66,8 +66,9 @@ The gateway implements the `Inference` gRPC service defined in `proto/inference. 1. Validates that both fields are non-empty. 2. Fetches the named provider record from the store. 3. Validates the provider by resolving its route (checking that the provider type is supported and has a usable API key). -4. Builds a managed route spec that stores only `provider_name` and `model_id`. The spec intentionally leaves `base_url`, `api_key`, and `protocols` empty -- these are resolved dynamically at bundle time from the provider record. -5. Upserts the route with name `inference.local`. Version starts at 1 and increments monotonically on each update. +4. By default, performs a lightweight provider-shaped probe against the resolved upstream endpoint (for example, a tiny chat/messages request with `max_tokens: 1`) to confirm the endpoint is reachable and accepts the expected auth/request shape. `--no-verify` disables this probe when the endpoint is not up yet. +5. Builds a managed route spec that stores only `provider_name` and `model_id`. The spec intentionally leaves `base_url`, `api_key`, and `protocols` empty -- these are resolved dynamically at bundle time from the provider record. +6. Upserts the route with name `inference.local`. Version starts at 1 and increments monotonically on each update. `GetClusterInference` returns `provider_name`, `model_id`, and `version` for the managed route. Returns `NOT_FOUND` if cluster inference is not configured. @@ -91,7 +92,7 @@ File: `proto/inference.proto` Key messages: -- `SetClusterInferenceRequest` -- `provider_name` + `model_id` +- `SetClusterInferenceRequest` -- `provider_name` + `model_id` + optional `no_verify` override, with verification enabled by default - `SetClusterInferenceResponse` -- `provider_name` + `model_id` + `version` - `GetInferenceBundleResponse` -- `repeated ResolvedRoute routes` + `revision` + `generated_at_ms` - `ResolvedRoute` -- `name`, `base_url`, `protocols`, `api_key`, `model_id`, `provider_type` @@ -296,13 +297,15 @@ The system route is stored as a separate `InferenceRoute` record in the gateway Cluster inference commands: -- `openshell cluster inference set --provider --model ` -- configures user-facing cluster inference -- `openshell cluster inference set --system --provider --model ` -- configures system inference -- `openshell cluster inference get` -- displays both user and system inference configuration -- `openshell cluster inference get --system` -- displays only the system inference configuration +- `openshell inference set --provider --model ` -- configures user-facing cluster inference +- `openshell inference set --system --provider --model ` -- configures system inference +- `openshell inference get` -- displays both user and system inference configuration +- `openshell inference get --system` -- displays only the system inference configuration The `--provider` flag references a provider record name (not a provider type). The provider must already exist in the cluster and have a supported inference type (`openai`, `anthropic`, or `nvidia`). +Inference writes verify by default. `--no-verify` is the explicit opt-out for endpoints that are not up yet. + ## Provider Discovery Files: diff --git a/architecture/sandbox-providers.md b/architecture/sandbox-providers.md index d5ca19850..dca36c59c 100644 --- a/architecture/sandbox-providers.md +++ b/architecture/sandbox-providers.md @@ -274,7 +274,7 @@ for (key, value) in provider_env { This uses `tokio::process::Command`. The `.env()` call adds each variable to the child's inherited environment without clearing it. The spawn path also explicitly removes -`NEMOCLAW_SSH_HANDSHAKE_SECRET` so the handshake secret does not leak into the agent +`OPENSHELL_SSH_HANDSHAKE_SECRET` so the handshake secret does not leak into the agent entrypoint process. After provider env vars, proxy env vars (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, @@ -370,7 +370,7 @@ Providers are stored with `object_type = "provider"` in the shared object store. provider store and are fetched at runtime by the sandbox supervisor. - Child processes never receive the raw provider secret values; they only receive placeholders, and the supervisor resolves those placeholders during outbound proxying. -- `NEMOCLAW_SSH_HANDSHAKE_SECRET` is required by the supervisor/SSH server path but is +- `OPENSHELL_SSH_HANDSHAKE_SECRET` is required by the supervisor/SSH server path but is explicitly kept out of spawned sandbox child-process environments. ## Test Strategy diff --git a/crates/openshell-bootstrap/Cargo.toml b/crates/openshell-bootstrap/Cargo.toml index c090a7f06..ab57ad57a 100644 --- a/crates/openshell-bootstrap/Cargo.toml +++ b/crates/openshell-bootstrap/Cargo.toml @@ -10,6 +10,7 @@ repository.workspace = true rust-version.workspace = true [dependencies] +openshell-core = { path = "../openshell-core" } base64 = "0.22" bollard = { version = "0.20", features = ["ssh"] } bytes = { workspace = true } diff --git a/crates/openshell-bootstrap/src/docker.rs b/crates/openshell-bootstrap/src/docker.rs index 1cb62b7b9..3dc832aa6 100644 --- a/crates/openshell-bootstrap/src/docker.rs +++ b/crates/openshell-bootstrap/src/docker.rs @@ -526,9 +526,13 @@ pub async fn ensure_container( port_bindings: Some(port_bindings), binds: Some(vec![format!("{}:/var/lib/rancher/k3s", volume_name(name))]), network_mode: Some(network_name(name)), - // Add host.docker.internal mapping for DNS resolution - // This allows the entrypoint script to configure CoreDNS to use the host gateway - extra_hosts: Some(vec!["host.docker.internal:host-gateway".to_string()]), + // Add host gateway aliases for DNS resolution. + // This allows both the entrypoint script and the running gateway + // process to reach services on the Docker host. + extra_hosts: Some(vec![ + "host.docker.internal:host-gateway".to_string(), + "host.openshell.internal:host-gateway".to_string(), + ]), ..Default::default() }; diff --git a/crates/openshell-bootstrap/src/edge_token.rs b/crates/openshell-bootstrap/src/edge_token.rs index 12b294d39..7b1892d14 100644 --- a/crates/openshell-bootstrap/src/edge_token.rs +++ b/crates/openshell-bootstrap/src/edge_token.rs @@ -9,6 +9,7 @@ use crate::paths::gateways_dir; use miette::{IntoDiagnostic, Result, WrapErr}; +use openshell_core::paths::{ensure_parent_dir_restricted, set_file_owner_only}; use std::path::PathBuf; /// Path to the stored edge auth token for a gateway. @@ -24,22 +25,12 @@ fn legacy_token_path(gateway_name: &str) -> Result { /// Store an edge authentication token for a gateway. pub fn store_edge_token(gateway_name: &str, token: &str) -> Result<()> { let path = edge_token_path(gateway_name)?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .into_diagnostic() - .wrap_err_with(|| format!("failed to create {}", parent.display()))?; - } + ensure_parent_dir_restricted(&path)?; std::fs::write(&path, token) .into_diagnostic() .wrap_err_with(|| format!("failed to write edge token to {}", path.display()))?; // Restrict permissions to owner-only (0600). - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) - .into_diagnostic() - .wrap_err("failed to set token file permissions")?; - } + set_file_owner_only(&path)?; Ok(()) } @@ -47,15 +38,34 @@ pub fn store_edge_token(gateway_name: &str, token: &str) -> Result<()> { /// /// Returns `None` if no token file exists or the file is empty. /// Falls back to the legacy `cf_token` path for backwards compatibility. +/// When loading from the legacy path, migrates the token to the new path +/// with proper permissions. pub fn load_edge_token(gateway_name: &str) -> Option { - // Try the new path first, then fall back to legacy. - let path = edge_token_path(gateway_name) + // Try the new path first. + if let Some(path) = edge_token_path(gateway_name).ok().filter(|p| p.exists()) { + let contents = std::fs::read_to_string(&path).ok()?; + let token = contents.trim().to_string(); + if !token.is_empty() { + return Some(token); + } + } + + // Fall back to the legacy cf_token path. + let legacy_path = legacy_token_path(gateway_name) .ok() - .filter(|p| p.exists()) - .or_else(|| legacy_token_path(gateway_name).ok().filter(|p| p.exists()))?; - let contents = std::fs::read_to_string(&path).ok()?; + .filter(|p| p.exists())?; + let contents = std::fs::read_to_string(&legacy_path).ok()?; let token = contents.trim().to_string(); - if token.is_empty() { None } else { Some(token) } + if token.is_empty() { + return None; + } + + // Migrate: write to new path with proper permissions, then remove legacy. + if store_edge_token(gateway_name, &token).is_ok() { + let _ = std::fs::remove_file(&legacy_path); + } + + Some(token) } /// Remove a stored edge authentication token. diff --git a/crates/openshell-bootstrap/src/metadata.rs b/crates/openshell-bootstrap/src/metadata.rs index 12e1b98a1..bd49ba8c9 100644 --- a/crates/openshell-bootstrap/src/metadata.rs +++ b/crates/openshell-bootstrap/src/metadata.rs @@ -4,6 +4,7 @@ use crate::RemoteOptions; use crate::paths::{active_gateway_path, gateways_dir, last_sandbox_path}; use miette::{IntoDiagnostic, Result, WrapErr}; +use openshell_core::paths::ensure_parent_dir_restricted; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -205,11 +206,7 @@ pub fn resolve_ssh_hostname(host: &str) -> String { pub fn store_gateway_metadata(name: &str, metadata: &GatewayMetadata) -> Result<()> { let path = stored_metadata_path(name)?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .into_diagnostic() - .wrap_err_with(|| format!("failed to create {}", parent.display()))?; - } + ensure_parent_dir_restricted(&path)?; let contents = serde_json::to_string_pretty(metadata) .into_diagnostic() .wrap_err("failed to serialize gateway metadata")?; @@ -237,11 +234,7 @@ pub fn get_gateway_metadata(name: &str) -> Option { /// Save the active gateway name to persistent storage. pub fn save_active_gateway(name: &str) -> Result<()> { let path = active_gateway_path()?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .into_diagnostic() - .wrap_err_with(|| format!("failed to create {}", parent.display()))?; - } + ensure_parent_dir_restricted(&path)?; std::fs::write(&path, name) .into_diagnostic() .wrap_err_with(|| format!("failed to write active gateway to {}", path.display()))?; @@ -261,11 +254,7 @@ pub fn load_active_gateway() -> Option { /// Save the last-used sandbox name for a gateway to persistent storage. pub fn save_last_sandbox(gateway: &str, sandbox: &str) -> Result<()> { let path = last_sandbox_path(gateway)?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .into_diagnostic() - .wrap_err_with(|| format!("failed to create {}", parent.display()))?; - } + ensure_parent_dir_restricted(&path)?; std::fs::write(&path, sandbox) .into_diagnostic() .wrap_err_with(|| format!("failed to write last sandbox to {}", path.display()))?; diff --git a/crates/openshell-bootstrap/src/mtls.rs b/crates/openshell-bootstrap/src/mtls.rs index 3767a639a..0c802e63c 100644 --- a/crates/openshell-bootstrap/src/mtls.rs +++ b/crates/openshell-bootstrap/src/mtls.rs @@ -1,15 +1,16 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use crate::paths::xdg_config_dir; use crate::pki::PkiBundle; use miette::{IntoDiagnostic, Result}; +use openshell_core::paths::{create_dir_restricted, set_file_owner_only, xdg_config_dir}; use std::path::PathBuf; /// Store the PKI bundle's client materials (ca.crt, tls.crt, tls.key) to the /// local filesystem so the CLI can use them for mTLS connections. /// /// Files are written atomically: temp dir -> validate -> rename over target. +/// Directories are created with `0o700` and `tls.key` is set to `0o600`. pub fn store_pki_bundle(name: &str, bundle: &PkiBundle) -> Result<()> { let dir = cli_mtls_dir(name)?; let temp_dir = cli_mtls_temp_dir(name)?; @@ -21,9 +22,9 @@ pub fn store_pki_bundle(name: &str, bundle: &PkiBundle) -> Result<()> { .map_err(|e| e.wrap_err(format!("failed to remove {}", temp_dir.display())))?; } - std::fs::create_dir_all(&temp_dir) - .into_diagnostic() - .map_err(|e| e.wrap_err(format!("failed to create {}", temp_dir.display())))?; + // Create the temp dir with restricted permissions so the private key + // is never world-readable, even momentarily. + create_dir_restricted(&temp_dir)?; std::fs::write(temp_dir.join("ca.crt"), &bundle.ca_cert_pem) .into_diagnostic() @@ -35,6 +36,9 @@ pub fn store_pki_bundle(name: &str, bundle: &PkiBundle) -> Result<()> { .into_diagnostic() .map_err(|e| e.wrap_err("failed to write tls.key"))?; + // Restrict the private key to owner-only. + set_file_owner_only(&temp_dir.join("tls.key"))?; + validate_cli_mtls_bundle_dir(&temp_dir)?; let had_backup = if dir.exists() { @@ -61,6 +65,9 @@ pub fn store_pki_bundle(name: &str, bundle: &PkiBundle) -> Result<()> { return Err(err); } + // Ensure the final directory also has restricted permissions after rename. + create_dir_restricted(&dir)?; + if had_backup { std::fs::remove_dir_all(&backup_dir) .into_diagnostic() diff --git a/crates/openshell-bootstrap/src/paths.rs b/crates/openshell-bootstrap/src/paths.rs index 84bd06e30..cd3cb7693 100644 --- a/crates/openshell-bootstrap/src/paths.rs +++ b/crates/openshell-bootstrap/src/paths.rs @@ -1,19 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use miette::{IntoDiagnostic, Result, WrapErr}; +use miette::Result; +use openshell_core::paths::xdg_config_dir; use std::path::PathBuf; -pub fn xdg_config_dir() -> Result { - if let Ok(path) = std::env::var("XDG_CONFIG_HOME") { - return Ok(PathBuf::from(path)); - } - let home = std::env::var("HOME") - .into_diagnostic() - .wrap_err("HOME is not set")?; - Ok(PathBuf::from(home).join(".config")) -} - /// Path to the file that stores the active gateway name. /// /// Location: `$XDG_CONFIG_HOME/openshell/active_gateway` diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index dcca47034..8995c3df0 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -906,9 +906,6 @@ enum InferenceCommands { system: bool, /// Skip endpoint verification before saving the route. - /// - /// Accepted now so scripts can opt out explicitly ahead of a future - /// default switch to verification. #[arg(long)] no_verify: bool, }, @@ -929,9 +926,6 @@ enum InferenceCommands { system: bool, /// Skip endpoint verification before saving the route. - /// - /// Accepted now so scripts can opt out explicitly ahead of a future - /// default switch to verification. #[arg(long)] no_verify: bool, }, @@ -1810,17 +1804,19 @@ async fn main() -> Result<()> { provider, model, system, - no_verify: _, + no_verify, } => { let route_name = if system { "sandbox-system" } else { "" }; - run::gateway_inference_set(endpoint, &provider, &model, route_name, &tls) - .await?; + run::gateway_inference_set( + endpoint, &provider, &model, route_name, no_verify, &tls, + ) + .await?; } InferenceCommands::Update { provider, model, system, - no_verify: _, + no_verify, } => { let route_name = if system { "sandbox-system" } else { "" }; run::gateway_inference_update( @@ -1828,6 +1824,7 @@ async fn main() -> Result<()> { provider.as_deref(), model.as_deref(), route_name, + no_verify, &tls, ) .await?; @@ -2559,6 +2556,54 @@ mod tests { )); } + #[test] + fn inference_set_accepts_no_verify_flag() { + let cli = Cli::try_parse_from([ + "openshell", + "inference", + "set", + "--provider", + "openai-dev", + "--model", + "gpt-4.1", + "--no-verify", + ]) + .expect("inference set should parse --no-verify"); + + assert!(matches!( + cli.command, + Some(Commands::Inference { + command: Some(InferenceCommands::Set { + no_verify: true, + .. + }) + }) + )); + } + + #[test] + fn inference_update_accepts_no_verify_flag() { + let cli = Cli::try_parse_from([ + "openshell", + "inference", + "update", + "--provider", + "openai-dev", + "--no-verify", + ]) + .expect("inference update should parse --no-verify"); + + assert!(matches!( + cli.command, + Some(Commands::Inference { + command: Some(InferenceCommands::Update { + no_verify: true, + .. + }) + }) + )); + } + #[test] fn completion_script_uses_openshell_command_name() { let script = normalize_completion_script( @@ -2747,52 +2792,4 @@ mod tests { other => panic!("expected SshProxy, got: {other:?}"), } } - - #[test] - fn inference_set_accepts_no_verify_flag() { - let cli = Cli::try_parse_from([ - "openshell", - "inference", - "set", - "--provider", - "openai-dev", - "--model", - "gpt-4.1", - "--no-verify", - ]) - .expect("inference set should parse --no-verify"); - - assert!(matches!( - cli.command, - Some(Commands::Inference { - command: Some(InferenceCommands::Set { - no_verify: true, - .. - }) - }) - )); - } - - #[test] - fn inference_update_accepts_no_verify_flag() { - let cli = Cli::try_parse_from([ - "openshell", - "inference", - "update", - "--provider", - "openai-dev", - "--no-verify", - ]) - .expect("inference update should parse --no-verify"); - - assert!(matches!( - cli.command, - Some(Commands::Inference { - command: Some(InferenceCommands::Update { - no_verify: true, - .. - }) - }) - )); - } } diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 42ecbbb1e..22123c1d3 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -40,7 +40,7 @@ use std::io::{IsTerminal, Write}; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{Duration, Instant}; -use tonic::Code; +use tonic::{Code, Status}; // Re-export SSH functions for backward compatibility pub use crate::ssh::{Editor, print_ssh_config}; @@ -3390,17 +3390,38 @@ pub async fn gateway_inference_set( provider_name: &str, model_id: &str, route_name: &str, + no_verify: bool, tls: &TlsOptions, ) -> Result<()> { + let progress = if std::io::stdout().is_terminal() { + let spinner = ProgressBar::new_spinner(); + spinner.set_style( + ProgressStyle::with_template("{spinner:.cyan} {msg} ({elapsed})") + .unwrap_or_else(|_| ProgressStyle::default_spinner()), + ); + spinner.set_message("Configuring inference..."); + spinner.enable_steady_tick(Duration::from_millis(120)); + Some(spinner) + } else { + None + }; + let mut client = grpc_inference_client(server, tls).await?; let response = client .set_cluster_inference(SetClusterInferenceRequest { provider_name: provider_name.to_string(), model_id: model_id.to_string(), route_name: route_name.to_string(), + verify: false, + no_verify, }) - .await - .into_diagnostic()?; + .await; + + if let Some(progress) = &progress { + progress.finish_and_clear(); + } + + let response = response.map_err(format_inference_status)?; let configured = response.into_inner(); let label = if configured.route_name == "sandbox-system" { @@ -3414,6 +3435,12 @@ pub async fn gateway_inference_set( println!(" {} {}", "Provider:".dimmed(), configured.provider_name); println!(" {} {}", "Model:".dimmed(), configured.model_id); println!(" {} {}", "Version:".dimmed(), configured.version); + if configured.validation_performed { + println!(" {}", "Validated Endpoints:".dimmed()); + for endpoint in configured.validated_endpoints { + println!(" - {} ({})", endpoint.url, endpoint.protocol); + } + } Ok(()) } @@ -3422,6 +3449,7 @@ pub async fn gateway_inference_update( provider_name: Option<&str>, model_id: Option<&str>, route_name: &str, + no_verify: bool, tls: &TlsOptions, ) -> Result<()> { if provider_name.is_none() && model_id.is_none() { @@ -3444,14 +3472,34 @@ pub async fn gateway_inference_update( let provider = provider_name.unwrap_or(¤t.provider_name); let model = model_id.unwrap_or(¤t.model_id); + let progress = if std::io::stdout().is_terminal() { + let spinner = ProgressBar::new_spinner(); + spinner.set_style( + ProgressStyle::with_template("{spinner:.cyan} {msg} ({elapsed})") + .unwrap_or_else(|_| ProgressStyle::default_spinner()), + ); + spinner.set_message("Configuring inference..."); + spinner.enable_steady_tick(Duration::from_millis(120)); + Some(spinner) + } else { + None + }; + let response = client .set_cluster_inference(SetClusterInferenceRequest { provider_name: provider.to_string(), model_id: model.to_string(), route_name: route_name.to_string(), + verify: false, + no_verify, }) - .await - .into_diagnostic()?; + .await; + + if let Some(progress) = &progress { + progress.finish_and_clear(); + } + + let response = response.map_err(format_inference_status)?; let configured = response.into_inner(); let label = if configured.route_name == "sandbox-system" { @@ -3465,6 +3513,12 @@ pub async fn gateway_inference_update( println!(" {} {}", "Provider:".dimmed(), configured.provider_name); println!(" {} {}", "Model:".dimmed(), configured.model_id); println!(" {} {}", "Version:".dimmed(), configured.version); + if configured.validation_performed { + println!(" {}", "Validated Endpoints:".dimmed()); + for endpoint in configured.validated_endpoints { + println!(" - {} ({})", endpoint.url, endpoint.protocol); + } + } Ok(()) } @@ -3536,6 +3590,16 @@ async fn print_inference_route( } } +fn format_inference_status(status: Status) -> miette::Report { + let message = status.message().trim(); + + if message.is_empty() { + return miette::miette!("inference configuration failed ({})", status.code()); + } + + miette::miette!("{message}") +} + pub fn git_repo_root(local_path: &Path) -> Result { let git_dir = if local_path.is_dir() { local_path diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index 7f8e7905f..4b284bff1 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -773,15 +773,9 @@ fn render_ssh_config(gateway: &str, name: &str) -> String { } fn openshell_ssh_config_path() -> Result { - let base = if let Ok(path) = std::env::var("XDG_CONFIG_HOME") { - PathBuf::from(path) - } else { - let home = std::env::var("HOME") - .into_diagnostic() - .wrap_err("HOME is not set")?; - PathBuf::from(home).join(".config") - }; - Ok(base.join("openshell").join("ssh_config")) + Ok(openshell_core::paths::xdg_config_dir()? + .join("openshell") + .join("ssh_config")) } fn user_ssh_config_path() -> Result { @@ -905,9 +899,7 @@ pub fn install_ssh_config(gateway: &str, name: &str) -> Result { ensure_openshell_include(&main_config, &managed_config)?; if let Some(parent) = managed_config.parent() { - fs::create_dir_all(parent) - .into_diagnostic() - .wrap_err("failed to create OpenShell config directory")?; + openshell_core::paths::create_dir_restricted(parent)?; } let alias = host_alias(name); diff --git a/crates/openshell-cli/src/tls.rs b/crates/openshell-cli/src/tls.rs index f24e9366c..5543a0812 100644 --- a/crates/openshell-cli/src/tls.rs +++ b/crates/openshell-cli/src/tls.rs @@ -150,13 +150,7 @@ fn sanitize_name(value: &str) -> String { } fn xdg_config_dir() -> Result { - if let Ok(path) = std::env::var("XDG_CONFIG_HOME") { - return Ok(PathBuf::from(path)); - } - let home = std::env::var("HOME") - .into_diagnostic() - .wrap_err("HOME is not set")?; - Ok(PathBuf::from(home).join(".config")) + openshell_core::paths::xdg_config_dir() } pub fn require_tls_materials(server: &str, tls: &TlsOptions) -> Result { diff --git a/crates/openshell-core/Cargo.toml b/crates/openshell-core/Cargo.toml index 6adf80394..eeedd11ab 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -24,5 +24,8 @@ url = { workspace = true } tonic-build = { workspace = true } protobuf-src = { workspace = true } +[dev-dependencies] +tempfile = "3" + [lints] workspace = true diff --git a/crates/openshell-core/src/forward.rs b/crates/openshell-core/src/forward.rs index 142252e57..c7b63fefb 100644 --- a/crates/openshell-core/src/forward.rs +++ b/crates/openshell-core/src/forward.rs @@ -6,6 +6,7 @@ //! Used by both the CLI (`openshell-cli`) and the TUI (`openshell-tui`) to //! start, stop, list, and track background SSH port forwards. +use crate::paths::{create_dir_restricted, xdg_config_dir}; use miette::{IntoDiagnostic, Result, WrapErr}; use std::net::TcpListener; use std::path::PathBuf; @@ -17,15 +18,7 @@ use std::process::Command; /// Base directory for forward PID files. pub fn forward_pid_dir() -> Result { - let base = if let Ok(path) = std::env::var("XDG_CONFIG_HOME") { - PathBuf::from(path) - } else { - let home = std::env::var("HOME") - .into_diagnostic() - .wrap_err("HOME is not set")?; - PathBuf::from(home).join(".config") - }; - Ok(base.join("openshell").join("forwards")) + Ok(xdg_config_dir()?.join("openshell").join("forwards")) } /// PID file path for a specific sandbox + port forward. @@ -44,9 +37,7 @@ pub fn write_forward_pid( bind_addr: &str, ) -> Result<()> { let dir = forward_pid_dir()?; - std::fs::create_dir_all(&dir) - .into_diagnostic() - .wrap_err("failed to create forwards directory")?; + create_dir_restricted(&dir)?; let path = forward_pid_path(name, port)?; std::fs::write(&path, format!("{pid}\t{sandbox_id}\t{bind_addr}")) .into_diagnostic() diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 4c91a91d1..9cf0d6203 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod config; pub mod error; pub mod forward; pub mod inference; +pub mod paths; pub mod proto; pub use config::{Config, TlsConfig}; diff --git a/crates/openshell-core/src/paths.rs b/crates/openshell-core/src/paths.rs new file mode 100644 index 000000000..bd9ce23d4 --- /dev/null +++ b/crates/openshell-core/src/paths.rs @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Centralized XDG config directory resolution and permission helpers. +//! +//! All `OpenShell` crates should use [`xdg_config_dir`] from this module instead +//! of reimplementing the XDG lookup. The permission helpers ensure that +//! sensitive files (private keys, tokens) and the directories containing them +//! are created with restrictive modes. + +use miette::{IntoDiagnostic, Result, WrapErr}; +use std::path::{Path, PathBuf}; + +/// Resolve the XDG config base directory. +/// +/// Returns `$XDG_CONFIG_HOME` if set, otherwise `$HOME/.config`. +pub fn xdg_config_dir() -> Result { + if let Ok(path) = std::env::var("XDG_CONFIG_HOME") { + return Ok(PathBuf::from(path)); + } + let home = std::env::var("HOME") + .into_diagnostic() + .wrap_err("HOME is not set")?; + Ok(PathBuf::from(home).join(".config")) +} + +/// The top-level `OpenShell` config directory: `$XDG_CONFIG_HOME/openshell/`. +pub fn openshell_config_dir() -> Result { + Ok(xdg_config_dir()?.join("openshell")) +} + +/// Create a directory (and parents) with owner-only permissions (`0o700`) on +/// Unix. On non-Unix platforms, falls back to default permissions. +/// +/// This should be used for any directory that contains sensitive material +/// (tokens, private keys, certificates). +pub fn create_dir_restricted(path: &Path) -> Result<()> { + std::fs::create_dir_all(path) + .into_diagnostic() + .wrap_err_with(|| format!("failed to create {}", path.display()))?; + set_dir_owner_only(path)?; + Ok(()) +} + +/// Set a directory to owner-only access (`0o700`) on Unix. +/// +/// No-op on non-Unix platforms. +pub fn set_dir_owner_only(path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .into_diagnostic() + .wrap_err_with(|| format!("failed to set permissions on {}", path.display()))?; + } + #[cfg(not(unix))] + let _ = path; + Ok(()) +} + +/// Set a file to owner-only read/write (`0o600`) on Unix. +/// +/// No-op on non-Unix platforms. +pub fn set_file_owner_only(path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .into_diagnostic() + .wrap_err_with(|| format!("failed to set permissions on {}", path.display()))?; + } + #[cfg(not(unix))] + let _ = path; + Ok(()) +} + +/// Ensure the parent directory of `path` exists with restricted permissions. +/// +/// Equivalent to `create_dir_restricted(path.parent())` but handles the case +/// where `path` has no parent gracefully. +pub fn ensure_parent_dir_restricted(path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + create_dir_restricted(parent)?; + } + Ok(()) +} + +/// Check whether a file has permissions that are too open (group/other readable). +/// +/// Returns `true` if the file has group or other read/write/execute bits set. +/// Always returns `false` on non-Unix platforms. +#[cfg(unix)] +pub fn is_file_permissions_too_open(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path) + .map(|m| m.permissions().mode() & 0o077 != 0) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn xdg_config_dir_respects_env() { + // This test checks the logic — actual env var mutation is unsafe so + // we rely on the integration tests in openshell-bootstrap for full + // round-trip testing. + let result = xdg_config_dir(); + assert!(result.is_ok()); + } + + #[test] + fn openshell_config_dir_appends_openshell() { + let dir = openshell_config_dir().unwrap(); + assert!( + dir.ends_with("openshell"), + "expected path ending with 'openshell', got: {dir:?}" + ); + } + + #[cfg(unix)] + #[test] + fn create_dir_restricted_sets_0o700() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("restricted"); + create_dir_restricted(&dir).unwrap(); + let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "expected 0700, got {mode:04o}"); + } + + #[cfg(unix)] + #[test] + fn set_file_owner_only_sets_0o600() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("secret"); + std::fs::write(&file, "secret-data").unwrap(); + set_file_owner_only(&file).unwrap(); + let mode = std::fs::metadata(&file).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "expected 0600, got {mode:04o}"); + } + + #[cfg(unix)] + #[test] + fn is_file_permissions_too_open_detects_world_readable() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("open-file"); + std::fs::write(&file, "data").unwrap(); + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(is_file_permissions_too_open(&file)); + } + + #[cfg(unix)] + #[test] + fn is_file_permissions_too_open_accepts_restricted() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("restricted-file"); + std::fs::write(&file, "data").unwrap(); + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert!(!is_file_permissions_too_open(&file)); + } +} diff --git a/crates/openshell-router/src/backend.rs b/crates/openshell-router/src/backend.rs index a060d3f9e..f9d538004 100644 --- a/crates/openshell-router/src/backend.rs +++ b/crates/openshell-router/src/backend.rs @@ -3,6 +3,35 @@ use crate::RouterError; use crate::config::{AuthHeader, ResolvedRoute}; +use crate::mock; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidatedEndpoint { + pub url: String, + pub protocol: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ValidationFailureKind { + RequestShape, + Credentials, + RateLimited, + Connectivity, + UpstreamHealth, + Unexpected, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidationFailure { + pub kind: ValidationFailureKind, + pub details: String, +} + +struct ValidationProbe { + path: &'static str, + protocol: &'static str, + body: bytes::Bytes, +} /// Response from a proxied HTTP request to a backend (fully buffered). #[derive(Debug)] @@ -128,6 +157,151 @@ async fn send_backend_request( }) } +fn validation_probe(route: &ResolvedRoute) -> Result { + if route + .protocols + .iter() + .any(|protocol| protocol == "openai_chat_completions") + { + return Ok(ValidationProbe { + path: "/v1/chat/completions", + protocol: "openai_chat_completions", + body: bytes::Bytes::from_static( + br#"{"messages":[{"role":"user","content":"ping"}],"max_tokens":1}"#, + ), + }); + } + + if route + .protocols + .iter() + .any(|protocol| protocol == "anthropic_messages") + { + return Ok(ValidationProbe { + path: "/v1/messages", + protocol: "anthropic_messages", + body: bytes::Bytes::from_static( + br#"{"messages":[{"role":"user","content":"ping"}],"max_tokens":1}"#, + ), + }); + } + + if route + .protocols + .iter() + .any(|protocol| protocol == "openai_responses") + { + return Ok(ValidationProbe { + path: "/v1/responses", + protocol: "openai_responses", + body: bytes::Bytes::from_static(br#"{"input":"ping","max_output_tokens":1}"#), + }); + } + + if route + .protocols + .iter() + .any(|protocol| protocol == "openai_completions") + { + return Ok(ValidationProbe { + path: "/v1/completions", + protocol: "openai_completions", + body: bytes::Bytes::from_static(br#"{"prompt":"ping","max_tokens":1}"#), + }); + } + + Err(ValidationFailure { + kind: ValidationFailureKind::RequestShape, + details: format!( + "route '{}' does not expose a writable inference protocol for validation", + route.name + ), + }) +} + +pub async fn verify_backend_endpoint( + client: &reqwest::Client, + route: &ResolvedRoute, +) -> Result { + let probe = validation_probe(route)?; + + if mock::is_mock_route(route) { + return Ok(ValidatedEndpoint { + url: build_backend_url(&route.endpoint, probe.path), + protocol: probe.protocol.to_string(), + }); + } + + let response = send_backend_request(client, route, "POST", probe.path, Vec::new(), probe.body) + .await + .map_err(|err| match err { + RouterError::UpstreamUnavailable(details) => ValidationFailure { + kind: ValidationFailureKind::Connectivity, + details, + }, + RouterError::Internal(details) | RouterError::UpstreamProtocol(details) => { + ValidationFailure { + kind: ValidationFailureKind::Unexpected, + details, + } + } + RouterError::RouteNotFound(details) + | RouterError::NoCompatibleRoute(details) + | RouterError::Unauthorized(details) => ValidationFailure { + kind: ValidationFailureKind::Unexpected, + details, + }, + })?; + let url = build_backend_url(&route.endpoint, probe.path); + + if response.status().is_success() { + return Ok(ValidatedEndpoint { + url, + protocol: probe.protocol.to_string(), + }); + } + + let status = response.status(); + let body = response.text().await.map_err(|e| ValidationFailure { + kind: ValidationFailureKind::Unexpected, + details: format!("failed to read validation response body: {e}"), + })?; + let body = body.trim(); + let body_suffix = if body.is_empty() { + String::new() + } else { + format!( + " Response body: {}", + body.chars().take(200).collect::() + ) + }; + + let details = match status.as_u16() { + 400 | 404 | 405 | 422 => { + format!("upstream rejected the validation request with HTTP {status}.{body_suffix}") + } + 401 | 403 => { + format!("upstream rejected credentials with HTTP {status}.{body_suffix}") + } + 429 => { + format!("upstream rate-limited the validation request with HTTP {status}.{body_suffix}") + } + 500..=599 => format!("upstream returned HTTP {status}.{body_suffix}"), + _ => format!("upstream returned unexpected HTTP {status}.{body_suffix}"), + }; + + Err(ValidationFailure { + kind: match status.as_u16() { + 400 | 404 | 405 | 422 => ValidationFailureKind::RequestShape, + 401 | 403 => ValidationFailureKind::Credentials, + 429 => ValidationFailureKind::RateLimited, + 500..=599 => ValidationFailureKind::UpstreamHealth, + _ => ValidationFailureKind::Unexpected, + }, + details, + }) +} + /// Extract status and headers from a [`reqwest::Response`]. fn extract_response_metadata(response: &reqwest::Response) -> (u16, Vec<(String, String)>) { let status = response.status().as_u16(); @@ -201,7 +375,11 @@ fn build_backend_url(endpoint: &str, path: &str) -> String { #[cfg(test)] mod tests { - use super::build_backend_url; + use super::{build_backend_url, verify_backend_endpoint}; + use crate::config::ResolvedRoute; + use openshell_core::inference::AuthHeader; + use wiremock::matchers::{body_partial_json, header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; #[test] fn build_backend_url_dedupes_v1_prefix() { @@ -226,4 +404,61 @@ mod tests { "https://api.openai.com/v1" ); } + + fn test_route(endpoint: &str, protocols: &[&str], auth: AuthHeader) -> ResolvedRoute { + ResolvedRoute { + name: "inference.local".to_string(), + endpoint: endpoint.to_string(), + model: "test-model".to_string(), + api_key: "sk-test".to_string(), + protocols: protocols.iter().map(|p| (*p).to_string()).collect(), + auth, + default_headers: vec![("anthropic-version".to_string(), "2023-06-01".to_string())], + } + } + + #[tokio::test] + async fn verify_backend_endpoint_uses_route_auth_and_shape() { + let mock_server = MockServer::start().await; + let route = test_route( + &mock_server.uri(), + &["anthropic_messages"], + AuthHeader::Custom("x-api-key"), + ); + + Mock::given(method("POST")) + .and(path("/v1/messages")) + .and(header("x-api-key", "sk-test")) + .and(header("anthropic-version", "2023-06-01")) + .and(body_partial_json(serde_json::json!({ + "model": "test-model", + "max_tokens": 1, + }))) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "msg_1"})), + ) + .mount(&mock_server) + .await; + + let client = reqwest::Client::builder().build().unwrap(); + let validated = verify_backend_endpoint(&client, &route).await.unwrap(); + + assert_eq!(validated.protocol, "anthropic_messages"); + assert_eq!(validated.url, format!("{}/v1/messages", mock_server.uri())); + } + + #[tokio::test] + async fn verify_backend_endpoint_accepts_mock_routes() { + let route = test_route( + "mock://test-backend", + &["openai_chat_completions"], + AuthHeader::Bearer, + ); + + let client = reqwest::Client::builder().build().unwrap(); + let validated = verify_backend_endpoint(&client, &route).await.unwrap(); + + assert_eq!(validated.protocol, "openai_chat_completions"); + assert_eq!(validated.url, "mock://test-backend/v1/chat/completions"); + } } diff --git a/crates/openshell-router/src/lib.rs b/crates/openshell-router/src/lib.rs index 4edd4f872..a5712d9a0 100644 --- a/crates/openshell-router/src/lib.rs +++ b/crates/openshell-router/src/lib.rs @@ -7,7 +7,10 @@ mod mock; use std::time::Duration; -pub use backend::{ProxyResponse, StreamingProxyResponse}; +pub use backend::{ + ProxyResponse, StreamingProxyResponse, ValidatedEndpoint, ValidationFailure, + ValidationFailureKind, verify_backend_endpoint, +}; use config::{ResolvedRoute, RouterConfig}; use tracing::info; diff --git a/crates/openshell-sandbox/src/process.rs b/crates/openshell-sandbox/src/process.rs index 635bcbcfa..cb10b8ca7 100644 --- a/crates/openshell-sandbox/src/process.rs +++ b/crates/openshell-sandbox/src/process.rs @@ -22,7 +22,7 @@ use std::process::Stdio; use tokio::process::{Child, Command}; use tracing::{debug, warn}; -const SSH_HANDSHAKE_SECRET_ENV: &str = "NEMOCLAW_SSH_HANDSHAKE_SECRET"; +const SSH_HANDSHAKE_SECRET_ENV: &str = "OPENSHELL_SSH_HANDSHAKE_SECRET"; fn inject_provider_env(cmd: &mut Command, provider_env: &HashMap) { for (key, value) in provider_env { diff --git a/crates/openshell-sandbox/src/ssh.rs b/crates/openshell-sandbox/src/ssh.rs index 619fa6a07..5c4ef862b 100644 --- a/crates/openshell-sandbox/src/ssh.rs +++ b/crates/openshell-sandbox/src/ssh.rs @@ -30,7 +30,7 @@ use tracing::{info, warn}; const PREFACE_MAGIC: &str = "NSSH1"; #[cfg(test)] -const SSH_HANDSHAKE_SECRET_ENV: &str = "NEMOCLAW_SSH_HANDSHAKE_SECRET"; +const SSH_HANDSHAKE_SECRET_ENV: &str = "OPENSHELL_SSH_HANDSHAKE_SECRET"; /// A time-bounded set of nonces used to detect replayed NSSH1 handshakes. /// Each entry records the `Instant` it was inserted; a background reaper task diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 7d53abe63..7bd721135 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -17,6 +17,7 @@ path = "src/main.rs" [dependencies] openshell-core = { path = "../openshell-core" } openshell-policy = { path = "../openshell-policy" } +openshell-router = { path = "../openshell-router" } # Async runtime tokio = { workspace = true } @@ -61,6 +62,7 @@ serde = { workspace = true } serde_json = { workspace = true } tokio-stream = { workspace = true } sqlx = { workspace = true } +reqwest = { workspace = true } kube = { workspace = true } kube-runtime = { workspace = true } k8s-openapi = { workspace = true } @@ -78,6 +80,7 @@ rcgen = { version = "0.13", features = ["crypto", "pem"] } tempfile = "3" tokio-tungstenite = { workspace = true } futures-util = "0.3" +wiremock = "0.6" [lints] workspace = true diff --git a/crates/openshell-server/src/inference.rs b/crates/openshell-server/src/inference.rs index f7a8427ec..c1354e758 100644 --- a/crates/openshell-server/src/inference.rs +++ b/crates/openshell-server/src/inference.rs @@ -4,9 +4,13 @@ use openshell_core::proto::{ ClusterInferenceConfig, GetClusterInferenceRequest, GetClusterInferenceResponse, GetInferenceBundleRequest, GetInferenceBundleResponse, InferenceRoute, Provider, ResolvedRoute, - SetClusterInferenceRequest, SetClusterInferenceResponse, inference_server::Inference, + SetClusterInferenceRequest, SetClusterInferenceResponse, ValidatedEndpoint, + inference_server::Inference, }; +use openshell_router::config::ResolvedRoute as RouterResolvedRoute; +use openshell_router::{ValidationFailureKind, verify_backend_endpoint}; use std::sync::Arc; +use std::time::Duration; use tonic::{Request, Response, Status}; use crate::{ @@ -76,15 +80,18 @@ impl Inference for InferenceService { ) -> Result, Status> { let req = request.into_inner(); let route_name = effective_route_name(&req.route_name)?; + let verify = !req.no_verify; let route = upsert_cluster_inference_route( self.state.store.as_ref(), route_name, &req.provider_name, &req.model_id, + verify, ) .await?; let config = route + .route .config .as_ref() .ok_or_else(|| Status::internal("managed route missing config"))?; @@ -92,8 +99,10 @@ impl Inference for InferenceService { Ok(Response::new(SetClusterInferenceResponse { provider_name: config.provider_name.clone(), model_id: config.model_id.clone(), - version: route.version, + version: route.route.version, route_name: route_name.to_string(), + validation_performed: !route.validation.is_empty(), + validated_endpoints: route.validation, })) } @@ -111,7 +120,7 @@ impl Inference for InferenceService { .map_err(|e| Status::internal(format!("fetch route failed: {e}")))? .ok_or_else(|| { Status::not_found(format!( - "inference route '{route_name}' is not configured; run 'openshell cluster inference set --provider --model '" + "inference route '{route_name}' is not configured; run 'openshell inference set --provider --model '" )) })?; @@ -140,7 +149,8 @@ async fn upsert_cluster_inference_route( route_name: &str, provider_name: &str, model_id: &str, -) -> Result { + verify: bool, +) -> Result { if provider_name.trim().is_empty() { return Err(Status::invalid_argument("provider_name is required")); } @@ -156,9 +166,12 @@ async fn upsert_cluster_inference_route( Status::failed_precondition(format!("provider '{provider_name}' not found")) })?; - // Validate provider shape at set time; endpoint/auth are resolved from the - // provider record when generating sandbox bundles. - let _ = resolve_provider_route(&provider)?; + let resolved = resolve_provider_route(&provider)?; + let validation = if verify { + vec![verify_provider_endpoint(&provider.name, model_id, &resolved).await?] + } else { + Vec::new() + }; let config = build_cluster_inference_config(&provider, model_id); @@ -188,7 +201,7 @@ async fn upsert_cluster_inference_route( .await .map_err(|e| Status::internal(format!("persist route failed: {e}")))?; - Ok(route) + Ok(UpsertedInferenceRoute { route, validation }) } fn build_cluster_inference_config(provider: &Provider, model_id: &str) -> ClusterInferenceConfig { @@ -200,9 +213,13 @@ fn build_cluster_inference_config(provider: &Provider, model_id: &str) -> Cluste struct ResolvedProviderRoute { provider_type: String, - base_url: String, - protocols: Vec, - api_key: String, + route: RouterResolvedRoute, +} + +#[derive(Debug)] +struct UpsertedInferenceRoute { + route: InferenceRoute, + validation: Vec, } fn resolve_provider_route(provider: &Provider) -> Result { @@ -238,12 +255,86 @@ fn resolve_provider_route(provider: &Provider) -> Result Status { + Status::failed_precondition(format!( + "failed to verify inference endpoint for provider '{provider_name}' and model '{model_id}' at '{base_url}': {details}. Next steps: {next_steps}, or retry with '--no-verify' if you want to skip verification" + )) +} + +fn validation_next_steps(kind: ValidationFailureKind) -> &'static str { + match kind { + ValidationFailureKind::Credentials => { + "verify the provider API key and any required auth headers" + } + ValidationFailureKind::RateLimited => { + "retry later or verify quota/limits on the upstream provider" + } + ValidationFailureKind::RequestShape => { + "confirm the provider type, base URL, and model identifier" + } + ValidationFailureKind::Connectivity => { + "check that the service is running, confirm the base URL and protocol, and verify credentials" + } + ValidationFailureKind::UpstreamHealth => { + "check whether the endpoint is healthy and serving requests" + } + ValidationFailureKind::Unexpected => { + "confirm the endpoint URL, protocol, credentials, and model identifier" + } + } +} + +async fn verify_provider_endpoint( + provider_name: &str, + model_id: &str, + route: &ResolvedProviderRoute, +) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|err| Status::internal(format!("build validation client failed: {err}")))?; + let mut route = route.route.clone(); + route.model = model_id.to_string(); + + verify_backend_endpoint(&client, &route) + .await + .map(|validated| ValidatedEndpoint { + url: validated.url, + protocol: validated.protocol, + }) + .map_err(|err| { + validation_failure( + provider_name, + model_id, + &route.endpoint, + &err.details, + validation_next_steps(err.kind), + ) + }) +} + fn find_provider_api_key(provider: &Provider, preferred_key_names: &[&str]) -> Option { for key in preferred_key_names { if let Some(value) = provider.credentials.get(*key) @@ -358,10 +449,10 @@ async fn resolve_route_by_name( Ok(Some(ResolvedRoute { name: route_name.to_string(), - base_url: resolved.base_url, + base_url: resolved.route.endpoint, model_id: config.model_id.clone(), - api_key: resolved.api_key, - protocols: resolved.protocols, + api_key: resolved.route.api_key, + protocols: resolved.route.protocols, provider_type: resolved.provider_type, })) } @@ -369,6 +460,8 @@ async fn resolve_route_by_name( #[cfg(test)] mod tests { use super::*; + use wiremock::matchers::{body_partial_json, header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; fn make_route(name: &str, provider_name: &str, model_id: &str) -> InferenceRoute { InferenceRoute { @@ -392,6 +485,20 @@ mod tests { } } + fn make_provider_with_base_url( + name: &str, + provider_type: &str, + key_name: &str, + key_value: &str, + base_url_key: &str, + base_url: &str, + ) -> Provider { + Provider { + config: std::iter::once((base_url_key.to_string(), base_url.to_string())).collect(), + ..make_provider(name, provider_type, key_name, key_value) + } + } + #[tokio::test] async fn upsert_cluster_route_creates_and_increments_version() { let store = Store::connect("sqlite::memory:?cache=shared") @@ -409,24 +516,26 @@ mod tests { CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4o", + false, ) .await .expect("first set should succeed"); - assert_eq!(first.name, CLUSTER_INFERENCE_ROUTE_NAME); - assert_eq!(first.version, 1); + assert_eq!(first.route.name, CLUSTER_INFERENCE_ROUTE_NAME); + assert_eq!(first.route.version, 1); let second = upsert_cluster_inference_route( &store, CLUSTER_INFERENCE_ROUTE_NAME, "openai-dev", "gpt-4.1", + false, ) .await .expect("second set should succeed"); - assert_eq!(second.version, 2); - assert_eq!(second.id, first.id); + assert_eq!(second.route.version, 2); + assert_eq!(second.route.id, first.route.id); - let config = second.config.as_ref().expect("config"); + let config = second.route.config.as_ref().expect("config"); assert_eq!(config.provider_name, "openai-dev"); assert_eq!(config.model_id, "gpt-4.1"); } @@ -630,13 +739,14 @@ mod tests { SANDBOX_SYSTEM_ROUTE_NAME, "anthropic-dev", "claude-sonnet-4-20250514", + false, ) .await .expect("should succeed"); - assert_eq!(route.name, SANDBOX_SYSTEM_ROUTE_NAME); - assert_eq!(route.version, 1); - let config = route.config.as_ref().expect("config"); + assert_eq!(route.route.name, SANDBOX_SYSTEM_ROUTE_NAME); + assert_eq!(route.route.version, 1); + let config = route.route.config.as_ref().expect("config"); assert_eq!(config.provider_name, "anthropic-dev"); assert_eq!(config.model_id, "claude-sonnet-4-20250514"); } @@ -715,6 +825,7 @@ mod tests { SANDBOX_SYSTEM_ROUTE_NAME, "openai-dev", "gpt-4o-mini", + false, ) .await .expect("upsert should succeed"); @@ -730,6 +841,141 @@ mod tests { assert_eq!(config.model_id, "gpt-4o-mini"); } + #[tokio::test] + async fn upsert_cluster_route_verifies_endpoint_when_requested() { + let store = Store::connect("sqlite::memory:?cache=shared") + .await + .expect("store"); + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(header("authorization", "Bearer sk-test")) + .and(body_partial_json(serde_json::json!({ + "model": "gpt-4o-mini", + "max_tokens": 1, + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "chatcmpl-123", + "object": "chat.completion", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}], + "model": "gpt-4o-mini" + }))) + .mount(&mock_server) + .await; + + let provider = make_provider_with_base_url( + "openai-dev", + "openai", + "OPENAI_API_KEY", + "sk-test", + "OPENAI_BASE_URL", + &mock_server.uri(), + ); + store + .put_message(&provider) + .await + .expect("persist provider"); + + let route = upsert_cluster_inference_route( + &store, + CLUSTER_INFERENCE_ROUTE_NAME, + "openai-dev", + "gpt-4o-mini", + true, + ) + .await + .expect("validation should succeed"); + + assert_eq!(route.route.version, 1); + assert_eq!(route.validation.len(), 1); + assert_eq!(route.validation[0].protocol, "openai_chat_completions"); + } + + #[tokio::test] + async fn upsert_cluster_route_rejects_failed_validation() { + let store = Store::connect("sqlite::memory:?cache=shared") + .await + .expect("store"); + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(ResponseTemplate::new(401).set_body_string("bad key")) + .mount(&mock_server) + .await; + + let provider = make_provider_with_base_url( + "openai-dev", + "openai", + "OPENAI_API_KEY", + "sk-test", + "OPENAI_BASE_URL", + &mock_server.uri(), + ); + store + .put_message(&provider) + .await + .expect("persist provider"); + + let err = upsert_cluster_inference_route( + &store, + CLUSTER_INFERENCE_ROUTE_NAME, + "openai-dev", + "gpt-4o-mini", + true, + ) + .await + .expect_err("validation should fail"); + + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + assert!( + err.message() + .contains("failed to verify inference endpoint") + ); + assert!(err.message().contains("verify the provider API key")); + assert!(err.message().contains("--no-verify")); + + let persisted = store + .get_message_by_name::(CLUSTER_INFERENCE_ROUTE_NAME) + .await + .expect("fetch route") + .is_none(); + assert!(persisted, "route should not persist on failed validation"); + } + + #[tokio::test] + async fn upsert_cluster_route_skips_validation_by_default() { + let store = Store::connect("sqlite::memory:?cache=shared") + .await + .expect("store"); + let provider = make_provider_with_base_url( + "openai-dev", + "openai", + "OPENAI_API_KEY", + "sk-test", + "OPENAI_BASE_URL", + "http://127.0.0.1:9", + ); + store + .put_message(&provider) + .await + .expect("persist provider"); + + let route = upsert_cluster_inference_route( + &store, + CLUSTER_INFERENCE_ROUTE_NAME, + "openai-dev", + "gpt-4o-mini", + false, + ) + .await + .expect("non-verified route should persist"); + + assert_eq!(route.route.version, 1); + assert!(route.validation.is_empty()); + } + #[test] fn effective_route_name_defaults_empty_to_inference_local() { assert_eq!( diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index f08910791..e0b36f944 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -473,10 +473,7 @@ async fn connect_to_gateway(name: &str, endpoint: &str) -> Result { /// Resolve the mTLS cert directory for a gateway. fn gateway_mtls_dir(name: &str) -> Option { - let config_dir = std::env::var("XDG_CONFIG_HOME") - .map(PathBuf::from) - .or_else(|_| std::env::var("HOME").map(|h| PathBuf::from(h).join(".config"))) - .ok()?; + let config_dir = openshell_core::paths::xdg_config_dir().ok()?; Some( config_dir .join("openshell") diff --git a/deploy/docker/Dockerfile.ci b/deploy/docker/Dockerfile.ci index 55ff8032b..f1a1405a4 100644 --- a/deploy/docker/Dockerfile.ci +++ b/deploy/docker/Dockerfile.ci @@ -77,8 +77,9 @@ RUN curl https://mise.run | sh COPY mise.toml /opt/mise/mise.toml COPY tasks/ /opt/mise/tasks/ WORKDIR /opt/mise -ARG MISE_GITHUB_TOKEN -RUN mise trust /opt/mise/mise.toml && \ +RUN --mount=type=secret,id=MISE_GITHUB_TOKEN \ + export MISE_GITHUB_TOKEN="$(cat /run/secrets/MISE_GITHUB_TOKEN 2>/dev/null || true)" && \ + mise trust /opt/mise/mise.toml && \ env -u RUSTC_WRAPPER mise install && \ /root/.cargo/bin/rustup component remove rust-docs || true && \ rm -rf /root/.rustup/toolchains/*/share/doc /root/.rustup/toolchains/*/share/man diff --git a/deploy/helm/openshell/templates/statefulset.yaml b/deploy/helm/openshell/templates/statefulset.yaml index 175b2606f..83ece4994 100644 --- a/deploy/helm/openshell/templates/statefulset.yaml +++ b/deploy/helm/openshell/templates/statefulset.yaml @@ -31,6 +31,13 @@ spec: {{- toYaml . | nindent 8 }} {{- end }} serviceAccountName: {{ include "openshell.serviceAccountName" . }} + {{- if .Values.server.hostGatewayIP }} + hostAliases: + - ip: {{ .Values.server.hostGatewayIP | quote }} + hostnames: + - host.docker.internal + - host.openshell.internal + {{- end }} securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} containers: diff --git a/docs/get-started/quickstart.md b/docs/get-started/quickstart.md index 4181b5d1b..bd11b936a 100644 --- a/docs/get-started/quickstart.md +++ b/docs/get-started/quickstart.md @@ -32,74 +32,28 @@ This page gets you from zero to a running, policy-enforced sandbox in two comman Before you begin, make sure you have: -- Python 3.12 or later. -- [uv](https://docs.astral.sh/uv/) installed. - Docker Desktop running on your machine. For a complete list of requirements, refer to {doc}`../reference/support-matrix`. ## Install the OpenShell CLI -Install the `openshell` package into a virtual environment. - -Activate your virtual environment: +Run the install script: ```console -$ uv venv && source .venv/bin/activate +$ curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh ``` -Install the CLI: +If you prefer [uv](https://docs.astral.sh/uv/): ```console -$ uv pip install openshell +$ uv tool install -U openshell ``` :::{tip} Run `openshell --help` in your terminal to see the full CLI reference, including all commands and flags. You can also clone the [NVIDIA OpenShell GitHub repository](https://github.com/NVIDIA/OpenShell) and use the `/openshell-cli` skill to load the CLI reference into your agent. ::: -## Deploy a Gateway (Optional) - -Running `openshell sandbox create` without a gateway auto-bootstraps a local one. -To start the gateway explicitly or deploy to a remote host, choose the tab that matches your setup. - -:::::{tab-set} - -::::{tab-item} Brev - -:::{note} -Deploy an OpenShell gateway on Brev by clicking **Deploy** on the [OpenShell Launchable](https://brev.nvidia.com/launchable/deploy/now?launchableID=env-3Ap3tL55zq4a8kew1AuW0FpSLsg). -::: - -After the instance starts running, find the gateway URL in the Brev console under **Using Secure Links**. -Copy the shareable URL for **port 8080**, which is the gateway endpoint. - -```console -$ openshell gateway add https://.brevlab.com -$ openshell status -``` - -:::: - -::::{tab-item} DGX Spark - -:::{note} -Set up your Spark with NVIDIA Sync first, or make sure SSH access is configured (such as SSH keys added to the host). -::: - -Deploy to a DGX Spark machine over SSH: - -```console -$ openshell gateway start --remote @.local -$ openshell status -``` - -After `openshell status` shows the gateway as healthy, all subsequent commands route through the SSH tunnel. - -:::: - -::::: - ## Create Your First OpenShell Sandbox Create a sandbox and launch an agent inside it. @@ -171,3 +125,47 @@ $ openshell sandbox create --from base ``` ::: + +:::: + +## Deploy a Gateway (Optional) + +Running `openshell sandbox create` without a gateway auto-bootstraps a local one. +To start the gateway explicitly or deploy to a remote host, choose the tab that matches your setup. + +:::::{tab-set} + +::::{tab-item} Brev + +:::{note} +Deploy an OpenShell gateway on Brev by clicking **Deploy** on the [OpenShell Launchable](https://brev.nvidia.com/launchable/deploy/now?launchableID=env-3Ap3tL55zq4a8kew1AuW0FpSLsg). +::: + +After the instance starts running, find the gateway URL in the Brev console under **Using Secure Links**. +Copy the shareable URL for **port 8080**, which is the gateway endpoint. + +```console +$ openshell gateway add https://.brevlab.com +$ openshell status +``` + +:::: + +::::{tab-item} DGX Spark + +:::{note} +Set up your Spark with NVIDIA Sync first, or make sure SSH access is configured (such as SSH keys added to the host). +::: + +Deploy to a DGX Spark machine over SSH: + +```console +$ openshell gateway start --remote @.local +$ openshell status +``` + +After `openshell status` shows the gateway as healthy, all subsequent commands route through the SSH tunnel. + +:::: + +::::: diff --git a/docs/inference/configure.md b/docs/inference/configure.md index b4dcd7817..bf0103a77 100644 --- a/docs/inference/configure.md +++ b/docs/inference/configure.md @@ -135,9 +135,9 @@ Use this endpoint when inference should stay local to the host for privacy and s ### Verify the Endpoint from a Sandbox -`openshell inference get` confirms the configuration was saved, but does not verify the upstream endpoint is reachable. The CLI also accepts `--no-verify` on `openshell inference set` and `openshell inference update` so automation can opt out explicitly ahead of a future verify-by-default rollout. +`openshell inference set` and `openshell inference update` verify the resolved upstream endpoint by default before saving the configuration. If the endpoint is not live yet, retry with `--no-verify` to persist the route without the probe. -To confirm end-to-end connectivity, connect to a sandbox and run: +`openshell inference get` confirms the current saved configuration. To confirm end-to-end connectivity from a sandbox, run: ```bash curl https://inference.local/v1/responses \ diff --git a/docs/sandboxes/community-sandboxes.md b/docs/sandboxes/community-sandboxes.md index 301107b5c..3bcb2d276 100644 --- a/docs/sandboxes/community-sandboxes.md +++ b/docs/sandboxes/community-sandboxes.md @@ -45,7 +45,6 @@ The following community sandboxes are available in the catalog. | `base` | Foundational image with system tools and dev environment | | `openclaw` | Open agent manipulation and control | | `sdg` | Synthetic data generation workflows | -| `simulation` | General-purpose simulation sandboxes | ## Use a Community Sandbox diff --git a/docs/sandboxes/index.md b/docs/sandboxes/index.md index f78c90b9e..59f2c0781 100644 --- a/docs/sandboxes/index.md +++ b/docs/sandboxes/index.md @@ -38,7 +38,7 @@ A gateway provisions sandboxes, brokers CLI requests, enforces policies, and man |---|---|---| | **Local** | Docker on your workstation | Solo development and quick iteration. The CLI auto-bootstraps a local gateway if none exists. | | **Remote** | Docker on a remote host via SSH | Running sandboxes on a more powerful machine (for example, a DGX Spark) while keeping the CLI on your laptop. | -| **Cloud** | Behind a reverse proxy (for example, Cloudflare Access) | Shared team environments where multiple users connect to the same gateway through browser-based authentication. | +| **Cloud** | Behind a reverse proxy (for example, Cloudflare Access) | Individual users accessing OpenShell behind a cloud VM. Cloud gateways are not yet intended for shared team access. | All three types expose the same API surface. Sandboxes, policies, and providers work identically regardless of where the gateway runs. The only difference is how the CLI reaches the gateway, whether through a direct Docker socket, SSH tunnel, or HTTPS through a proxy. diff --git a/docs/sandboxes/manage-gateways.md b/docs/sandboxes/manage-gateways.md index 1cc708783..07449d64f 100644 --- a/docs/sandboxes/manage-gateways.md +++ b/docs/sandboxes/manage-gateways.md @@ -164,15 +164,6 @@ $ openshell gateway info $ openshell gateway info --name my-remote-cluster ``` -## Advanced Start Options - -| Flag | Purpose | -|---|---| -| `--gpu` | Enable NVIDIA GPU passthrough. Requires NVIDIA drivers and the Container Toolkit on the host. | -| `--plaintext` | Listen on HTTP instead of mTLS. Use behind a TLS-terminating reverse proxy. | -| `--disable-gateway-auth` | Skip mTLS client certificate checks. Use when a reverse proxy cannot forward client certs. | -| `--registry-token` | GitHub PAT with `read:packages` scope for pulling container images from ghcr.io. Also configurable with `OPENSHELL_REGISTRY_TOKEN`. | - ## Stop and Destroy Stop a gateway while preserving its state for later restart: diff --git a/docs/sandboxes/manage-providers.md b/docs/sandboxes/manage-providers.md index 5052c4eea..d238e3eeb 100644 --- a/docs/sandboxes/manage-providers.md +++ b/docs/sandboxes/manage-providers.md @@ -137,7 +137,6 @@ The following provider types are supported. | `gitlab` | `GITLAB_TOKEN`, `GLAB_TOKEN`, `CI_JOB_TOKEN` | GitLab API, `glab` CLI | | `nvidia` | `NVIDIA_API_KEY` | NVIDIA API Catalog | | `generic` | User-defined | Any service with custom credentials | -| `outlook` | *(none: no auto-discovery)* | Microsoft Outlook integration | :::{tip} Use the `generic` type for any service not listed above. You define the diff --git a/docs/sandboxes/manage-sandboxes.md b/docs/sandboxes/manage-sandboxes.md index fa2e4c8f0..d2790d57e 100644 --- a/docs/sandboxes/manage-sandboxes.md +++ b/docs/sandboxes/manage-sandboxes.md @@ -69,27 +69,6 @@ $ openshell sandbox create --from my-registry.example.com/my-image:latest The CLI resolves community names against the [OpenShell Community](https://github.com/NVIDIA/OpenShell-Community) catalog, pulls the bundled Dockerfile and policy, builds the image locally, and creates the sandbox. For the full catalog and how to contribute your own, refer to {doc}`community-sandboxes`. -The following is an example of a fully specified creation command: - -```console -$ openshell sandbox create \ - --name dev \ - --provider my-claude \ - --policy policy.yaml \ - --upload \ - -- claude -``` - -Additional `sandbox create` flags: - -| Flag | Purpose | -|---|---| -| `--no-keep` | Delete the sandbox automatically after the initial command exits | -| `--forward ` | Forward a local port to the sandbox before running | -| `--tty` / `--no-tty` | Force or disable pseudo-terminal allocation | -| `--no-bootstrap` | Error if no gateway is available instead of auto-bootstrapping | -| `--no-auto-providers` | Error if required providers are missing instead of prompting | - ## Connect to a Sandbox Open an SSH session into a running sandbox: @@ -203,7 +182,6 @@ Deleting a sandbox stops all processes, releases resources, and purges injected ```console $ openshell sandbox delete my-sandbox -$ openshell sandbox delete --all ``` ## Next Steps diff --git a/e2e/python/test_sandbox_providers.py b/e2e/python/test_sandbox_providers.py index b16df7589..899b6e464 100644 --- a/e2e/python/test_sandbox_providers.py +++ b/e2e/python/test_sandbox_providers.py @@ -194,7 +194,7 @@ def test_ssh_handshake_secret_not_visible_in_exec_environment( def read_handshake_secret() -> str: import os - return os.environ.get("NEMOCLAW_SSH_HANDSHAKE_SECRET", "NOT_SET") + return os.environ.get("OPENSHELL_SSH_HANDSHAKE_SECRET", "NOT_SET") with sandbox(delete_on_exit=True) as sb: result = sb.exec_python(read_handshake_secret) diff --git a/e2e/rust/tests/host_gateway_alias.rs b/e2e/rust/tests/host_gateway_alias.rs index 76d8be573..547a9238f 100644 --- a/e2e/rust/tests/host_gateway_alias.rs +++ b/e2e/rust/tests/host_gateway_alias.rs @@ -16,6 +16,7 @@ use tempfile::NamedTempFile; use tokio::time::{interval, timeout}; const INFERENCE_PROVIDER_NAME: &str = "e2e-host-inference"; +const INFERENCE_PROVIDER_UNREACHABLE_NAME: &str = "e2e-host-inference-unreachable"; const TEST_SERVER_IMAGE: &str = "python:3.13-alpine"; static INFERENCE_ROUTE_LOCK: Mutex<()> = Mutex::new(()); @@ -177,6 +178,22 @@ async fn delete_provider(name: &str) { let _ = cmd.status().await; } +async fn create_openai_provider(name: &str, base_url: &str) -> Result { + run_cli(&[ + "provider", + "create", + "--name", + name, + "--type", + "openai", + "--credential", + "OPENAI_API_KEY=dummy", + "--config", + &format!("OPENAI_BASE_URL={base_url}"), + ]) + .await +} + fn write_policy(port: u16) -> Result { let mut file = NamedTempFile::new().map_err(|e| format!("create temp policy file: {e}"))?; let policy = format!( @@ -282,36 +299,33 @@ async fn sandbox_inference_local_routes_to_host_openshell_internal() { delete_provider(INFERENCE_PROVIDER_NAME).await; } - run_cli(&[ - "provider", - "create", - "--name", + let create_output = create_openai_provider( INFERENCE_PROVIDER_NAME, - "--type", - "openai", - "--credential", - "OPENAI_API_KEY=dummy", - "--config", - &format!( - "OPENAI_BASE_URL=http://host.openshell.internal:{}/v1", - server.port - ), - ]) + &format!("http://host.openshell.internal:{}/v1", server.port), + ) .await .expect("create host-backed OpenAI provider"); - run_cli(&[ + let inference_output = run_cli(&[ "inference", "set", "--provider", INFERENCE_PROVIDER_NAME, "--model", "host-echo-model", - "--no-verify", ]) .await .expect("point inference.local at host-backed provider"); + assert!( + inference_output.contains("Validated Endpoints:"), + "expected verification details in output:\n{inference_output}" + ); + assert!( + inference_output.contains("/v1/chat/completions (openai_chat_completions)"), + "expected validated endpoint in output:\n{inference_output}" + ); + let guard = SandboxGuard::create(&[ "--", "curl", @@ -338,4 +352,69 @@ async fn sandbox_inference_local_routes_to_host_openshell_internal() { "expected sandbox to receive echoed inference content:\n{}", guard.create_output ); + + let _ = create_output; +} + +#[tokio::test] +async fn inference_set_supports_no_verify_for_unreachable_endpoint() { + let _inference_lock = INFERENCE_ROUTE_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + let current_inference = run_cli(&["inference", "get"]) + .await + .expect("read current inference config"); + if !current_inference.contains("Not configured") { + eprintln!("Skipping test: existing inference config would make shared state unsafe"); + return; + } + + if provider_exists(INFERENCE_PROVIDER_UNREACHABLE_NAME).await { + delete_provider(INFERENCE_PROVIDER_UNREACHABLE_NAME).await; + } + + create_openai_provider( + INFERENCE_PROVIDER_UNREACHABLE_NAME, + "http://host.openshell.internal:9/v1", + ) + .await + .expect("create unreachable OpenAI provider"); + + let verify_err = run_cli(&[ + "inference", + "set", + "--provider", + INFERENCE_PROVIDER_UNREACHABLE_NAME, + "--model", + "host-echo-model", + ]) + .await + .expect_err("default verification should fail for unreachable endpoint"); + + assert!( + verify_err.contains("failed to verify inference endpoint"), + "expected verification failure output:\n{verify_err}" + ); + assert!( + verify_err.contains("--no-verify"), + "expected retry hint in failure output:\n{verify_err}" + ); + + let no_verify_output = run_cli(&[ + "inference", + "set", + "--provider", + INFERENCE_PROVIDER_UNREACHABLE_NAME, + "--model", + "host-echo-model", + "--no-verify", + ]) + .await + .expect("no-verify should bypass validation"); + + assert!( + !no_verify_output.contains("Validated Endpoints:"), + "did not expect validation output when bypassing verification:\n{no_verify_output}" + ); } diff --git a/mise.toml b/mise.toml index 5b6ce8353..115b13c05 100644 --- a/mise.toml +++ b/mise.toml @@ -36,9 +36,6 @@ UV_CACHE_DIR = "{{config_root}}/.cache/uv" RUSTC_WRAPPER = "sccache" SCCACHE_DIR = "{{config_root}}/.cache/sccache" -# Artifact registries -NAV_PYPI_REPOSITORY_URL = "https://urm.nvidia.com/artifactory/api/pypi/nv-shared-pypi-local" - # Shared build constants (overridable via environment) DOCKER_BUILDKIT = "1" diff --git a/proto/inference.proto b/proto/inference.proto index 11670c4a9..a15f4b842 100644 --- a/proto/inference.proto +++ b/proto/inference.proto @@ -56,6 +56,15 @@ message SetClusterInferenceRequest { // Route name to target. Empty string defaults to "inference.local" (user-facing). // Use "sandbox-system" for the sandbox system-level inference route. string route_name = 3; + // Verify the resolved upstream endpoint synchronously before persistence. + bool verify = 4; + // Skip synchronous endpoint validation before persistence. + bool no_verify = 5; +} + +message ValidatedEndpoint { + string url = 1; + string protocol = 2; } message SetClusterInferenceResponse { @@ -64,6 +73,10 @@ message SetClusterInferenceResponse { uint64 version = 3; // Route name that was configured. string route_name = 4; + // Whether endpoint verification ran as part of this request. + bool validation_performed = 5; + // The concrete endpoints that were probed during validation, when available. + repeated ValidatedEndpoint validated_endpoints = 6; } message GetClusterInferenceRequest { diff --git a/pyproject.toml b/pyproject.toml index 731a80333..60d5177d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,9 +8,10 @@ build-backend = "maturin" [project] name = "openshell" dynamic = ["version"] -description = "OpenShell is the runtime environment for autonomous agents" +description = "OpenShell is the safe, private runtime for autonomous AI agents. " license = "Apache-2.0" readme = "README.md" +authors = [{ name = "NVIDIA Inc." }] requires-python = ">=3.12" dependencies = [ "cloudpickle>=3.0", @@ -20,11 +21,24 @@ dependencies = [ classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "Intended Audience :: Information Technology", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Rust", + "License :: OSI Approved :: Apache Software License", + "Topic :: Security", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS :: MacOS X", ] +keywords = ["agent", "sandbox", "nvidia"] + +[project.urls] +source = "https://github.com/NVIDIA/OpenShell" +documentation = "https://docs.nvidia.com/openshell/latest/" [dependency-groups] dev = [ diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index 7b48ab3b9..19bdcdf65 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -398,11 +398,13 @@ class InferenceRouteClient: *, provider_name: str, model_id: str, + no_verify: bool = False, ) -> ClusterInferenceConfig: response = self._stub.SetClusterInference( inference_pb2.SetClusterInferenceRequest( provider_name=provider_name, model_id=model_id, + no_verify=no_verify, ), timeout=self._timeout, ) diff --git a/python/openshell/sandbox_test.py b/python/openshell/sandbox_test.py index 4c0eebcda..c0148dcc7 100644 --- a/python/openshell/sandbox_test.py +++ b/python/openshell/sandbox_test.py @@ -10,6 +10,7 @@ from openshell._proto import openshell_pb2 from openshell.sandbox import ( _PYTHON_CLOUDPICKLE_BOOTSTRAP, _SANDBOX_PYTHON_BIN, + InferenceRouteClient, SandboxClient, ) @@ -33,6 +34,22 @@ class _FakeStub: ) +class _FakeInferenceStub: + def __init__(self) -> None: + self.request = None + + def SetClusterInference(self, request: Any, timeout: float | None = None) -> Any: + self.request = request + _ = timeout + + class _Response: + provider_name = request.provider_name + model_id = request.model_id + version = 1 + + return _Response() + + def _client_with_fake_stub(stub: _FakeStub) -> SandboxClient: client = cast("SandboxClient", object.__new__(SandboxClient)) client._timeout = 30.0 @@ -120,3 +137,19 @@ def test_from_active_cluster_prefers_openshell_gateway_env( assert client._cluster_name == gateway_name finally: client.close() + + +def test_inference_set_cluster_forwards_no_verify_flag() -> None: + stub = _FakeInferenceStub() + client = cast("InferenceRouteClient", object.__new__(InferenceRouteClient)) + client._timeout = 30.0 + client._stub = cast("Any", stub) + + client.set_cluster( + provider_name="openai-dev", + model_id="gpt-4.1", + no_verify=True, + ) + + assert stub.request is not None + assert stub.request.no_verify is True diff --git a/tasks/scripts/release.py b/tasks/scripts/release.py index e4daef7af..57e8fbf51 100644 --- a/tasks/scripts/release.py +++ b/tasks/scripts/release.py @@ -6,8 +6,6 @@ from __future__ import annotations import argparse -import fnmatch -import os import re import subprocess from dataclasses import dataclass @@ -69,53 +67,6 @@ def _compute_versions() -> Versions: ) -def python_publish( - version: str | None = None, wheel_glob: str = "openshell-*.whl" -) -> None: - if version is None: - version = _compute_versions().python - - repo_url = os.getenv("NAV_PYPI_REPOSITORY_URL") - username = os.getenv("NAV_PYPI_USERNAME") - password = os.getenv("NAV_PYPI_PASSWORD") - - if not repo_url or not username or not password: - raise SystemExit( - "Auth is not set up for publishing to PyPI registry, see CONTRIBUTING.md for details." - ) - - env = dict(os.environ) - env["UV_PUBLISH_USERNAME"] = username - env["UV_PUBLISH_PASSWORD"] = password - - wheels_dir = _repo_root() / "target" / "wheels" - wheels_dir.mkdir(parents=True, exist_ok=True) - - wheel_paths = sorted( - p - for p in wheels_dir.glob("*.whl") - if f"-{version}-" in p.name and fnmatch.fnmatch(p.name, wheel_glob) - ) - - if not wheel_paths: - available = "\n".join(sorted(p.name for p in wheels_dir.glob("*.whl"))) - raise SystemExit( - f"No wheels found for version {version} in {wheels_dir}.\n" - f"Available wheels:\n{available or '(none)'}" - ) - - _run( - [ - "uv", - "publish", - "--publish-url", - repo_url, - *[str(path) for path in wheel_paths], - ], - env=env, - ) - - def get_version(format: str) -> None: versions = _compute_versions() if format == "python": @@ -147,18 +98,6 @@ def build_parser() -> argparse.ArgumentParser: "--docker", action="store_true", help="Print Docker version only." ) - python_publish_parser = sub.add_parser( - "python-publish", help="Publish python wheel." - ) - python_publish_parser.add_argument( - "--version", help="Version to publish (defaults to computed version)." - ) - python_publish_parser.add_argument( - "--wheel-glob", - default="openshell-*.whl", - help="Filename glob for wheels to publish (defaults to all openshell wheels).", - ) - return parser @@ -175,8 +114,6 @@ def main() -> None: get_version("docker") else: get_version("all") - elif args.command == "python-publish": - python_publish(version=args.version, wheel_glob=args.wheel_glob) if __name__ == "__main__":