From 61d42102c1186a323e6f1dbff76d8ef67da6d91a Mon Sep 17 00:00:00 2001 From: Jim Meyer Date: Fri, 9 Oct 2026 17:22:15 +0000 Subject: [PATCH 01/32] feat(ci): gate merges on maintainer approval via a silent status check (#3176) Adds the OpenShell / Maintainer Approval status check. It passes only when someone listed in MAINTAINERS.md has approved the pull request, and it notifies no one: a required status check is the only native primitive that enforces who must approve without requesting a review from them. The job runs on pull_request_review and merge_group with read-only permissions and posts nothing anywhere; its exit code is the check result. Both MAINTAINERS.md and the decision helper are read from main via a sparse checkout, never from the pull request ref, so a contributor cannot add themselves to the list or rewrite the decision logic and self-approve. Because pull_request_review runs the pull request's own copy of the workflow file, CODEOWNERS now covers the whole enforcement surface -- workflows, actions, CODEOWNERS itself, the zizmor config, MAINTAINERS.md, and the enforcement scripts -- naming the outside collaborators explicitly alongside the team, since org policy keeps them off GitHub teams. The ruleset's native code owner requirement is what blocks a merge that disables the gate, regardless of what the tampered check reports. Also adds a change-alert workflow that comments the approver-set delta on pull requests touching MAINTAINERS.md, and documents both in CONTRIBUTING. Signed-off-by: Jim Meyer --- .github/CODEOWNERS | 34 +++++- .github/workflows/maintainer-approval.yml | 73 ++++++++++++ .../workflows/maintainers-change-alert.yml | 107 ++++++++++++++++++ .github/zizmor.yml | 1 + CONTRIBUTING.md | 6 + 5 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/maintainer-approval.yml create mode 100644 .github/workflows/maintainers-change-alert.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4d32b414c1..14f2e5ef61 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,35 @@ -# Broad ownership — core team reviews everything +## Broad ownership — core team reviews everything * @NVIDIA/openshell-codeowners @mrunalp @sjenning @derekwaynecarr -# Vouch list — maintainers only (bot commits bypass, but manual edits need review) +## Vouch list — maintainers only (bot commits bypass, but manual edits need review) .github/VOUCHED.td @NVIDIA/openshell-codeowners + +## Merge enforcement — these paths decide who can merge, so they notify the full +# list of maintainers to raise scrutiny on changes here. +# +# PR checks run local to the branch, so unless the check specifically avoids +# using branch-local files, a PR could change its own gates and appear to pass +# them. Covering the required checks here closes that gap. +# +# The whole workflows directory is covered, not just the approval gate, because +# any workflow can claim a required check context or ask for a broader token. +# +# Normally, we'd use a GitHub team to cover all maintainers, but org-level +# policies prevent outside collaborators from being on a GH team at this time +# so we spell them out explicitly. + +# Core check mechanisms +/.github/workflows/ @NVIDIA/openshell-codeowners @mrunalp @sjenning @derekwaynecarr +/.github/actions/ @NVIDIA/openshell-codeowners @mrunalp @sjenning @derekwaynecarr +/.github/CODEOWNERS @NVIDIA/openshell-codeowners @mrunalp @sjenning @derekwaynecarr +/.github/zizmor.yml @NVIDIA/openshell-codeowners @mrunalp @sjenning @derekwaynecarr + +# `MAINTAINERS.md` maintainer approval checks +/MAINTAINERS.md @NVIDIA/openshell-codeowners @mrunalp @sjenning @derekwaynecarr +/tasks/scripts/check_maintainer_approval.py @NVIDIA/openshell-codeowners @mrunalp @sjenning @derekwaynecarr +/tasks/scripts/alert_maintainer_change.py @NVIDIA/openshell-codeowners @mrunalp @sjenning @derekwaynecarr +/tasks/scripts/check_maintainer_approval_test.py @NVIDIA/openshell-codeowners @mrunalp @sjenning @derekwaynecarr +/tasks/scripts/alert_maintainer_change_test.py @NVIDIA/openshell-codeowners @mrunalp @sjenning @derekwaynecarr + +# Auditable SBOM check +/tasks/scripts/verify-image-sbom.sh @NVIDIA/openshell-codeowners @mrunalp @sjenning @derekwaynecarr diff --git a/.github/workflows/maintainer-approval.yml b/.github/workflows/maintainer-approval.yml new file mode 100644 index 0000000000..f478ed97ac --- /dev/null +++ b/.github/workflows/maintainer-approval.yml @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Maintainer Approval + +on: + merge_group: + types: [checks_requested] + pull_request_review: + types: [submitted, dismissed] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + maintainer-approval: + # This name is the required status check context. Changing it silently + # breaks the ruleset entry that gates merges on this job. + name: OpenShell / Maintainer Approval + if: github.repository_owner == 'NVIDIA' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # Check out the default branch, never the pull request head. Both the + # maintainer list and the decision helper must come from main: reading + # either from the pull request ref would let a contributor add themselves + # to the list, or rewrite the decision logic, and self-approve. + - name: Check out the maintainer list and helper + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + sparse-checkout: | + MAINTAINERS.md + tasks/scripts/check_maintainer_approval.py + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Require an approving review from a maintainer + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + # merge_group carries no pull request in its payload, but the queue + # branch names the entry: gh-readonly-queue/main/pr-3027-. + MERGE_GROUP_REF: ${{ github.ref_name }} + shell: bash + run: | + set -euo pipefail + + if [ -z "${PR_NUMBER:-}" ]; then + if [[ "$MERGE_GROUP_REF" =~ /pr-([0-9]+)-[0-9a-f]+$ ]]; then + PR_NUMBER="${BASH_REMATCH[1]}" + else + # Fail closed: an unrecognised ref must never satisfy the gate. + echo "::error::No pull request resolved from '$MERGE_GROUP_REF'." + exit 1 + fi + fi + + gh api --paginate "repos/$GH_REPO/pulls/$PR_NUMBER/reviews" --jq '.[]' \ + | jq -s '.' > reviews.json + + # Exits non-zero when no maintainer's latest decisive review is an + # approval, and when MAINTAINERS.md yields no handles. That exit code + # is the check result; nothing is posted anywhere. + python3 tasks/scripts/check_maintainer_approval.py \ + --maintainers MAINTAINERS.md \ + --reviews reviews.json diff --git a/.github/workflows/maintainers-change-alert.yml b/.github/workflows/maintainers-change-alert.yml new file mode 100644 index 0000000000..d04052d51b --- /dev/null +++ b/.github/workflows/maintainers-change-alert.yml @@ -0,0 +1,107 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: Maintainers Change Alert + +on: + pull_request_target: + types: [opened, reopened, synchronize] + paths: + - MAINTAINERS.md + +permissions: + contents: read + pull-requests: write + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + describe-change: + name: Comment on the approver set change if MAINTAINERS.md has changed + if: github.repository_owner == 'NVIDIA' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # Default branch only. The helper must be the reviewed version, not + # whatever the pull request happens to contain. + - name: Check out the change-alert helper + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + sparse-checkout: tasks/scripts/alert_maintainer_change.py + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Post the maintainer delta + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + # Single source of truth for the comment marker: the script emits + # it as the first line of the body, the lookup below matches on it. + COMMENT_MARKER: "" + shell: bash + run: | + set -euo pipefail + + # Fetching file contents is reading data, not executing it. The head + # revision is never checked out or run. + # + # A 404 means the file genuinely does not exist at that revision — a + # pull request that adds or deletes MAINTAINERS.md — and yields an + # empty side of the comparison. Every other failure is fatal: an empty + # file from a rate limit or a 5xx would render as "every maintainer + # was just added" or "nothing changed", both of which mislead the + # reviewer about who can merge code. + fetch_maintainers() { + local ref="$1" out="$2" err + err="$(mktemp)" + if gh api -H "Accept: application/vnd.github.raw" \ + "repos/$GH_REPO/contents/MAINTAINERS.md?ref=$ref" > "$out" 2>"$err"; then + rm -f "$err" + return 0 + fi + if grep -q 'HTTP 404' "$err"; then + rm -f "$err" + : > "$out" + return 0 + fi + echo "::error::Could not fetch MAINTAINERS.md at $ref" + cat "$err" >&2 + rm -f "$err" + return 1 + } + + fetch_maintainers "$BASE_SHA" before.md + fetch_maintainers "$HEAD_SHA" after.md + + # An unparseable result exits non-zero, but the comment explaining + # why still has to be posted before this job fails. + status=0 + python3 tasks/scripts/alert_maintainer_change.py \ + --before before.md --after after.md > body.md || status=$? + + # No output means the approver set did not change. A comment saying + # so is noise, so post nothing. + if [ -s body.md ]; then + cat body.md >> "$GITHUB_STEP_SUMMARY" + + # Update the existing comment rather than stacking one per push. + COMMENT_ID=$(gh api --paginate "repos/$GH_REPO/issues/$PR_NUMBER/comments" \ + --jq '.[] | select(.body | startswith($ENV.COMMENT_MARKER)) | .id' \ + | head -n 1) + + if [ -n "$COMMENT_ID" ]; then + gh api --method PATCH "repos/$GH_REPO/issues/comments/$COMMENT_ID" \ + -F "body=@body.md" >/dev/null + else + gh api --method POST "repos/$GH_REPO/issues/$PR_NUMBER/comments" \ + -F "body=@body.md" >/dev/null + fi + fi + + exit "$status" diff --git a/.github/zizmor.yml b/.github/zizmor.yml index 7b1e035b77..27a89f878e 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -8,6 +8,7 @@ rules: # head code. Keep each suppression scoped to its reviewed trigger block. - dco.yml:3 - e2e-label-help.yml:13 + - maintainers-change-alert.yml:6 - release-canary.yml:3 - required-ci-gates.yml:3 - vouch-check.yml:3 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ea44911a0..77bc795197 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,6 +68,12 @@ Do not start substantial issue-backed work until a maintainer has accepted the i Use agents and the repository skills as needed to understand the affected code, evaluate tradeoffs, implement the smallest coherent change, and verify it. The pull request should explain what changed and how it was tested; it should not substitute an agent transcript for the contributor's understanding. +Every pull request must be approved by someone listed in [MAINTAINERS.md](MAINTAINERS.md) before it can merge. This is enforced by the `OpenShell / Maintainer Approval` status check, which turns green once one of those reviewers approves. Reviews from other contributors are welcome and count toward the general approval requirement, but they do not satisfy this check. + +Pull requests that touch the enforcement machinery itself — anything under `.github/workflows/`, `.github/actions/`, `MAINTAINERS.md`, `.github/CODEOWNERS`, or the enforcement scripts in `tasks/scripts/` — additionally require a code owner's approval, listed in [.github/CODEOWNERS](.github/CODEOWNERS). The status check reads its inputs from `main`, but a workflow triggered by a review runs the pull request's own copy of the workflow file, so GitHub's native code owner requirement is what keeps a change from disabling the gate that would have blocked it. + +Maintainers are not requested automatically. If your pull request has been idle, ask for a reviewer in the pull request or in the CNCF Slack channel rather than waiting. + ## Agent Skills OpenShell keeps skills for using the product separate from skills for developing the repository. From d789ec613b829044e4580f6cda7f5d4648a31285 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Fri, 9 Oct 2026 19:52:57 +0000 Subject: [PATCH 02/32] test(tmachine): use K3s ClusterIP, wait for gateway, add failure diagnostics (#4258) * test(k3s): connect tmachine clients directly to ClusterIP Signed-off-by: Matthew Grossman * test(conformance): wait for a connected gateway on every installer Replace the K3s-specific boot readiness hook with one shared gateway wait in the conformance playbook. Skip conformance and collect the existing diagnostics when the gateway never connects. Discover the gateway Service port with its ClusterIP, and capture both iptables backends in K3s diagnostics. Signed-off-by: Matthew Grossman * test(tmachine): centralize gateway diagnostics and trim K3s changes Move failure diagnostics into one openshell_diagnostics role that collects the systemd gateway journal and, when K3s is installed, the K3s diagnostics. Drop install-time diagnostics so the K3s installer change stays minimal, revert unrelated skill edits, and shorten the CI.md note. Signed-off-by: Matthew Grossman --------- Signed-off-by: Matthew Grossman --- .github/workflows/integration-runner.yml | 9 ++++ CI.md | 6 +++ tests/ansible/playbooks/conformance/cli.yaml | 48 ++++++++++------- tests/ansible/playbooks/openshell-k3s.yaml | 51 ++++++++---------- .../files/collect-k3s.sh | 54 +++++++++++++++++++ .../openshell_diagnostics/tasks/main.yaml | 51 ++++++++++++++++++ 6 files changed, 170 insertions(+), 49 deletions(-) create mode 100644 tests/ansible/roles/openshell_diagnostics/files/collect-k3s.sh create mode 100644 tests/ansible/roles/openshell_diagnostics/tasks/main.yaml diff --git a/.github/workflows/integration-runner.yml b/.github/workflows/integration-runner.yml index 89cc0ca920..4be954eced 100644 --- a/.github/workflows/integration-runner.yml +++ b/.github/workflows/integration-runner.yml @@ -89,3 +89,12 @@ jobs: # Retry dependency preparation, then execute the test suite once. nix build --no-link .#tmachine || nix build --no-link .#tmachine nix run .#tmachine -- test "${ENVIRONMENT}" "${INSTALLER}" "${TESTSUITE}" + + - name: Upload tmachine diagnostics + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: tmachine-diagnostics-${{ inputs.category }}-${{ matrix.environment }}-${{ matrix.installer }}-${{ matrix.testsuite }} + path: artifacts/tmachine-diagnostics + if-no-files-found: ignore + retention-days: 7 diff --git a/CI.md b/CI.md index b3c188e99a..de6c53511b 100644 --- a/CI.md +++ b/CI.md @@ -149,6 +149,12 @@ compatibility baseline for the v1beta1 Sandbox API. It does not track the local K3s development default, currently v1.0.3. OpenShell also supports v0.4.6 through its v1alpha1 fallback, so v0.5.0 is not the overall minimum supported version. +The `ubuntu-k3s` lane registers the gateway's ClusterIP Service directly, so +recreating the Service requires reprovisioning the fixture. Before conformance, +every installer waits up to five minutes for a connected gateway; interactive +shells skip the wait. K3s failures upload diagnostics as `tmachine-diagnostics-*` +artifacts. + ### Run only the policy advisor conformance tests Manually dispatch `Integration Tests` on the candidate branch with an diff --git a/tests/ansible/playbooks/conformance/cli.yaml b/tests/ansible/playbooks/conformance/cli.yaml index dd8ce2b592..11eb07e36d 100644 --- a/tests/ansible/playbooks/conformance/cli.yaml +++ b/tests/ansible/playbooks/conformance/cli.yaml @@ -5,6 +5,9 @@ - name: Run OpenShell conformance tests hosts: all gather_facts: false + vars: + # Conformance is skipped when the gateway never connects. + conformance_failed: "{{ gateway_status is failed or conformance_result.rc != 0 }}" tasks: - name: Wait for SSH ansible.builtin.wait_for_connection: @@ -55,6 +58,20 @@ register: openshell_cli changed_when: false + # Every test boot restarts the gateway, and some installers take minutes to + # recover. Wait once here; the runner's per-scenario preflight stays short. + - name: Wait for the registered gateway + ansible.builtin.command: + argv: [timeout, 10s, "{{ openshell_cli.stdout }}", status, --output, json] + register: gateway_status + changed_when: false + until: >- + gateway_status.rc == 0 and + (gateway_status.stdout | from_json).status == 'connected' + retries: 60 + delay: 5 + ignore_errors: true + - name: Run OpenShell conformance archive ansible.builtin.command: argv: @@ -77,34 +94,25 @@ register: conformance_result changed_when: false failed_when: false + when: gateway_status is succeeded # Preserve both streams for failures without adding passing-test output to # every tmachine run. - name: Show OpenShell conformance diagnostics ansible.builtin.debug: var: conformance_result - when: conformance_result.rc != 0 + when: conformance_failed - - name: Read OpenShell gateway logs - become: true - ansible.builtin.command: - argv: - - journalctl - - --no-pager - - --lines - - "500" - - _SYSTEMD_UNIT=openshell-gateway.service - - "+" - - _SYSTEMD_USER_UNIT=openshell-gateway.service - register: openshell_gateway_logs - changed_when: false - failed_when: false - when: conformance_result.rc != 0 + - name: Collect OpenShell gateway diagnostics + ansible.builtin.include_role: + name: openshell_diagnostics + when: conformance_failed - - name: Show OpenShell gateway logs - ansible.builtin.debug: - var: openshell_gateway_logs.stdout_lines - when: conformance_result.rc != 0 + - name: Require a connected gateway + ansible.builtin.assert: + that: + - gateway_status is succeeded + fail_msg: OpenShell gateway did not connect before conformance; see gateway diagnostics - name: Require OpenShell conformance success ansible.builtin.assert: diff --git a/tests/ansible/playbooks/openshell-k3s.yaml b/tests/ansible/playbooks/openshell-k3s.yaml index dd00aa4bda..591262a933 100644 --- a/tests/ansible/playbooks/openshell-k3s.yaml +++ b/tests/ansible/playbooks/openshell-k3s.yaml @@ -137,39 +137,32 @@ environment: KUBECONFIG: /etc/rancher/k3s/k3s.yaml - - name: Install gateway port-forward service - ansible.builtin.copy: - dest: /etc/systemd/system/openshell-k3s-port-forward.service - mode: "0644" - content: | - [Unit] - Description=OpenShell K3s gateway port forward - After=k3s.service - Requires=k3s.service - - [Service] - ExecStart=/usr/local/bin/k3s kubectl --namespace openshell port-forward --address 127.0.0.1 service/openshell 17670:8080 - Restart=always - RestartSec=1 - - [Install] - WantedBy=multi-user.target - - - name: Start gateway port-forward service - ansible.builtin.systemd_service: - name: openshell-k3s-port-forward.service - daemon_reload: true - enabled: true - state: started + - name: Discover the gateway ClusterIP and gRPC port + ansible.builtin.command: + argv: + - /usr/local/bin/k3s + - kubectl + - --request-timeout=10s + - --namespace + - openshell + - get + - service + - openshell + - --output=jsonpath={.spec.clusterIP}:{.spec.ports[?(@.name=="grpc")].port} + register: k3s_gateway_address + changed_when: false - - name: Wait for OpenShell gateway - ansible.builtin.wait_for: - host: 127.0.0.1 - port: 17670 - timeout: 60 + # Rejects headless Services (ClusterIP "None") and a missing grpc port. + - name: Require a gateway Service address + ansible.builtin.assert: + that: + - k3s_gateway_address.stdout is match('^[0-9.]+:[0-9]+$') + fail_msg: "Unexpected gateway Service address: {{ k3s_gateway_address.stdout }}" - name: Register OpenShell gateway for test client hosts: all gather_facts: false + vars: + openshell_client_gateway_endpoint: "http://{{ k3s_gateway_address.stdout }}" roles: - openshell_client diff --git a/tests/ansible/roles/openshell_diagnostics/files/collect-k3s.sh b/tests/ansible/roles/openshell_diagnostics/files/collect-k3s.sh new file mode 100644 index 0000000000..c20334bb13 --- /dev/null +++ b/tests/ansible/roles/openshell_diagnostics/files/collect-k3s.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# No kubeconfig, Secrets, environment dumps, or complete Pod specifications. +set -u +umask 077 +mkdir -p /var/lib/openshell/diagnostics +output=/var/lib/openshell/diagnostics/k3s.txt + +collect() { + printf '\n### %s\n' "$1" + shift + # Bound both stalled commands and unusually large log streams. + timeout 10s "$@" 2>&1 | tail -c 262144 + printf '\ncommand status: %s\n' "${PIPESTATUS[0]}" +} + +{ + date --utc --iso-8601=seconds + collect 'K3s service state' systemctl show k3s.service \ + -p ActiveState -p SubState -p Result -p NRestarts -p ExecMainStatus \ + -p ExecMainPID -p ActiveEnterTimestamp -p ExecMainStartTimestamp \ + -p StartLimitIntervalUSec -p StartLimitBurst -p RestartUSec + collect 'K3s boot journal' journalctl -b --no-pager --lines=500 -u k3s.service + # kube-proxy may program either iptables backend; dump whichever exist. + # shellcheck disable=SC2016 # Expanded by the inner shell. + collect 'Gateway Service routing rules' bash -c ' + for save in iptables-nft-save iptables-legacy-save; do + command -v "$save" >/dev/null || continue + echo "# $save" + "$save" 2>/dev/null | awk "/openshell\/openshell:grpc/ { print }" + done' + collect 'K3s version' /usr/local/bin/k3s --version + collect 'API readiness' /usr/local/bin/k3s kubectl --request-timeout=5s get --raw=/readyz + collect 'Nodes' /usr/local/bin/k3s kubectl --request-timeout=5s get nodes -o wide + collect 'Gateway Pod identity and state' /usr/local/bin/k3s kubectl --request-timeout=5s -n openshell get pods \ + -o 'custom-columns=NAME:.metadata.name,UID:.metadata.uid,PHASE:.status.phase,CONDITIONS:.status.conditions,CONTAINERS:.status.containerStatuses' + collect 'Gateway Services' /usr/local/bin/k3s kubectl --request-timeout=5s -n openshell get services \ + -o 'custom-columns=NAME:.metadata.name,TYPE:.spec.type,CLUSTERIP:.spec.clusterIP,SELECTOR:.spec.selector,PORTS:.spec.ports' + collect 'Test client gateway registrations' runuser -u tmachine -- openshell gateway list --output json + collect 'Test client gateway status' runuser -u tmachine -- openshell status --output json + collect 'Endpoint readiness' /usr/local/bin/k3s kubectl --request-timeout=5s -n openshell get endpointslices \ + -o 'custom-columns=NAME:.metadata.name,PORTS:.ports,ADDRESSES:.endpoints[*].addresses,CONDITIONS:.endpoints[*].conditions' + collect 'Gateway events' /usr/local/bin/k3s kubectl --request-timeout=5s -n openshell get events --sort-by=.lastTimestamp + collect 'Gateway current logs' /usr/local/bin/k3s kubectl --request-timeout=5s -n openshell logs openshell-0 -c openshell-gateway --tail=300 --timestamps + collect 'Gateway previous logs' /usr/local/bin/k3s kubectl --request-timeout=5s -n openshell logs openshell-0 -c openshell-gateway --previous --tail=300 --timestamps + collect 'Memory' free -m + collect 'Disk' df -h / /var/lib/rancher/k3s +} | sed -E \ + -e 's/(Bearer )[A-Za-z0-9._~+\/-]+/\1[REDACTED]/gI' \ + -e 's/((token|password|secret|authorization)[" ]*[=:][" ]*)[^ ,"]+/\1[REDACTED]/gI' \ + > "$output" +cat "$output" diff --git a/tests/ansible/roles/openshell_diagnostics/tasks/main.yaml b/tests/ansible/roles/openshell_diagnostics/tasks/main.yaml new file mode 100644 index 0000000000..74a204ab96 --- /dev/null +++ b/tests/ansible/roles/openshell_diagnostics/tasks/main.yaml @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +# Collect whatever applies to the installed gateway before the VM exits. +- name: Read OpenShell gateway logs + become: true + ansible.builtin.command: + argv: + - journalctl + - --no-pager + - --lines + - "500" + - _SYSTEMD_UNIT=openshell-gateway.service + - "+" + - _SYSTEMD_USER_UNIT=openshell-gateway.service + register: openshell_gateway_logs + changed_when: false + failed_when: false + +- name: Show OpenShell gateway logs + ansible.builtin.debug: + var: openshell_gateway_logs.stdout_lines + +- name: Check for K3s + become: true + ansible.builtin.stat: + path: /usr/local/bin/k3s + register: k3s_diagnostics_binary + failed_when: false + +- name: Preserve K3s diagnostics + when: k3s_diagnostics_binary.stat.exists | default(false) + become: true + block: + - name: Collect bounded K3s diagnostics + ansible.builtin.script: collect-k3s.sh + register: k3s_diagnostics_collection + changed_when: false + failed_when: false + + - name: Show K3s diagnostics + ansible.builtin.debug: + var: k3s_diagnostics_collection.stdout_lines + + - name: Fetch K3s diagnostics + ansible.builtin.fetch: + src: /var/lib/openshell/diagnostics/k3s.txt + dest: "{{ role_path }}/../../../../artifacts/tmachine-diagnostics/{{ inventory_hostname }}/k3s.txt" + flat: true + failed_when: false From 0ccc6b9a053fdaf47700e91c1b8ed858f2afed4f Mon Sep 17 00:00:00 2001 From: stevenlee-oai Date: Fri, 9 Oct 2026 21:22:58 +0000 Subject: [PATCH 03/32] fix(core): preserve issued identity in credential aliases (#4390) Signed-off-by: Steven Lee --- .../src/provider_credentials.rs | 93 ++++++++++++++++++- crates/openshell-core/src/secrets.rs | 21 +++-- docs/how-it-works/providers/overview.mdx | 18 ++++ 3 files changed, 125 insertions(+), 7 deletions(-) diff --git a/crates/openshell-core/src/provider_credentials.rs b/crates/openshell-core/src/provider_credentials.rs index 8e3a5fe850..c87e8c934d 100644 --- a/crates/openshell-core/src/provider_credentials.rs +++ b/crates/openshell-core/src/provider_credentials.rs @@ -1047,6 +1047,15 @@ mod tests { use super::*; use crate::google_cloud; + fn jwt_alias(placeholder: &str) -> String { + // Claims are irrelevant to injection authority: retain the exact issued + // identity in the signature while giving clients a parseable JWT envelope. + let suffix = placeholder + .strip_prefix("openshell:resolve:env:") + .expect("issued placeholder"); + format!("e30.e30.OPENSHELL-RESOLVE-ENV-{suffix}") + } + #[test] fn body_classification_distinguishes_literal_foreign_bound_and_revoked() { use crate::secrets::body::BodyCredentialError; @@ -1721,6 +1730,14 @@ mod tests { None, "the expired access token must fail closed" ); + assert!( + state + .resolver_for_endpoint("api.example.com", 443, "/v1") + .expect("resolver") + .rewrite_header_value(&format!("Bearer {}", jwt_alias(&workload_placeholder))) + .is_err(), + "wrapping a placeholder must not bypass access-token expiry" + ); state .install_bound_environment( @@ -1741,6 +1758,12 @@ mod tests { resolver.resolve_placeholder(&workload_placeholder), Some("current") ); + assert_eq!( + resolver + .rewrite_header_value(&format!("Bearer {}", jwt_alias(&workload_placeholder))) + .expect("refresh updates the JWT alias"), + Some("Bearer current".to_string()) + ); assert_eq!( resolver.resolve_placeholder("openshell:resolve:env:v2_API_KEY"), None, @@ -1762,6 +1785,10 @@ mod tests { ) .expect("initial stable binding"); let workload_placeholder = state.snapshot().child_env["API_KEY"].clone(); + let request = format!( + "GET /v1 HTTP/1.1\r\nAuthorization: Bearer {}\r\n\r\n", + jwt_alias(&workload_placeholder) + ); for revision in 2..=13 { let token = format!("token-{revision}"); @@ -1783,6 +1810,16 @@ mod tests { .resolve_placeholder(&workload_placeholder), Some(token.as_str()) ); + let resolver = state + .resolver_for_endpoint("api.example.com", 443, "/v1") + .expect("resolver"); + let rewritten = + crate::secrets::rewrite_http_header_block(request.as_bytes(), Some(&resolver)) + .expect("the unchanged JWT alias uses the current token"); + assert_eq!( + String::from_utf8(rewritten.rewritten).expect("HTTP header"), + format!("GET /v1 HTTP/1.1\r\nAuthorization: Bearer {token}\r\n\r\n") + ); } let reconstructed = ProviderCredentialState::from_bound_environment( @@ -1805,6 +1842,14 @@ mod tests { .resolve_placeholder(&workload_placeholder), Some("token-14") ); + assert_eq!( + reconstructed + .resolver_for_endpoint("api.example.com", 443, "/v1") + .expect("resolver") + .rewrite_header_value(&format!("Bearer {}", jwt_alias(&workload_placeholder))) + .expect("the JWT alias survives supervisor reconstruction"), + Some("Bearer token-14".to_string()) + ); } #[test] @@ -1843,6 +1888,24 @@ mod tests { .resolver_for_endpoint("new.example.com", 443, "/v2/messages") .expect("current resolver"); assert_eq!(current.resolve_placeholder(&old_placeholder), None); + assert!( + current + .rewrite_header_value(&format!("Bearer {}", jwt_alias(&old_placeholder))) + .is_err(), + "a JWT alias must not carry a revoked identity into the new epoch" + ); + for alias in [ + "e30.e30.OPENSHELL-RESOLVE-ENV-API_KEY", + "e30.e30.OPENSHELL-RESOLVE-ENV-sshort_API_KEY", + "e30.e30.OPENSHELL-RESOLVE-ENV-v18446744073709551616_API_KEY", + ] { + assert!( + current + .rewrite_header_value(&format!("Bearer {alias}")) + .is_err(), + "identityless and malformed JWT aliases must fail closed" + ); + } assert_eq!( current.resolve_placeholder(&state.snapshot().child_env["API_KEY"]), Some("new") @@ -1854,6 +1917,13 @@ mod tests { .rewrite_header_value(&state.snapshot().child_env["API_KEY"]) .expect_err("new handle must not resolve at the old endpoint"); assert!(error.is_endpoint_mismatch()); + let error = wrong_endpoint + .rewrite_header_value(&format!( + "Bearer {}", + jwt_alias(&state.snapshot().child_env["API_KEY"]) + )) + .expect_err("JWT wrapping preserves endpoint authorization"); + assert!(error.is_endpoint_mismatch()); } #[test] @@ -1870,6 +1940,11 @@ mod tests { Vec::new(), ) .expect("initial bindings"); + let workload_placeholder = state.snapshot().child_env["API_KEY"].clone(); + let request = format!( + "POST /v2/inference HTTP/1.1\r\nHost: api.example.com\r\nAuthorization: Bearer {}\r\nContent-Length: 2\r\n\r\n{{}}", + jwt_alias(&workload_placeholder) + ); for revision in [10, 100, 9, 101, 8, 102, 7, 103, 6] { state @@ -1888,13 +1963,20 @@ mod tests { } let resolver = state - .resolver_for_endpoint("api.example.com", 443, "/v1") + .resolver_for_endpoint("api.example.com", 443, "/v2/inference") .expect("resolver"); assert_eq!( resolver.resolve_placeholder("openshell:resolve:env:v50_API_KEY"), Some("secret-6"), "an aged-out placeholder may use the current secret across revisions in both numeric directions while its provider identity is unchanged" ); + let rewritten = + crate::secrets::rewrite_http_header_block(request.as_bytes(), Some(&resolver)) + .expect("an aged-out JWT alias retains the issued provider identity"); + assert_eq!( + String::from_utf8(rewritten.rewritten).expect("HTTP request"), + "POST /v2/inference HTTP/1.1\r\nHost: api.example.com\r\nAuthorization: Bearer secret-6\r\nContent-Length: 2\r\n\r\n{}" + ); } #[test] @@ -1930,6 +2012,15 @@ mod tests { None, "an opaque revision from another provider identity must fail closed even when it is numerically greater" ); + assert!( + resolver + .rewrite_header_value(&format!( + "Bearer {}", + jwt_alias(&format!("openshell:resolve:env:v{}_API_KEY", u64::MAX)) + )) + .is_err(), + "a JWT alias must not resolve a replacement provider's credential" + ); assert_eq!( resolver.resolve_placeholder("openshell:resolve:env:API_KEY"), None, diff --git a/crates/openshell-core/src/secrets.rs b/crates/openshell-core/src/secrets.rs index 479d2b4a2c..e17cd34a8b 100644 --- a/crates/openshell-core/src/secrets.rs +++ b/crates/openshell-core/src/secrets.rs @@ -431,14 +431,17 @@ impl SecretResolver { /// Returns `None` if the placeholder is unknown or the resolved value /// contains prohibited control characters (CRLF, null byte). pub fn resolve_placeholder(&self, value: &str) -> Option<&str> { + // Normalize aliases before identity checks so a wrapped revision or stable + // handle retains the original endpoint, epoch, and expiration constraints. + let canonical_alias = alias_env_key(value).map(placeholder_for_env_key); + let value = canonical_alias.as_deref().unwrap_or(value); if placeholder_env_key(value).is_some_and(|key| self.identity_bound_env_keys.contains(key)) && revisioned_placeholder_parts(value).is_none() && stable_placeholder_parts(value).is_none() { - // Canonical placeholders and provider-shaped aliases carry no - // credential identity. Endpoint-bound request input must use the - // revision-scoped placeholder issued to the workload so a stale - // process cannot resolve a replacement provider's credential. + // Unversioned placeholders carry no credential identity. Endpoint-bound + // request input must retain the issued revision or stable handle so a + // stale process cannot resolve a replacement provider's credential. return None; } let secret = if let Some(secret) = self.by_placeholder.get(value) { @@ -449,7 +452,7 @@ impl SecretResolver { // credential refresh. For endpoint-bound credentials, permit that // fallback only when the exact opaque revision belongs to the // current provider-identity epoch. - let key = revisioned_placeholder_env_key(value).or_else(|| alias_env_key(value))?; + let key = revisioned_placeholder_env_key(value)?; if let Some((revision, key)) = revisioned_placeholder_parts(value) && self.identity_bound_env_keys.contains(key) && self @@ -782,7 +785,13 @@ fn placeholder_env_key(token: &str) -> Option<&str> { .map(|(_, key)| key) .or_else(|| revisioned_placeholder_env_key(token)) .or_else(|| token.strip_prefix(PLACEHOLDER_PREFIX)) - .or_else(|| alias_env_key(token)) + .or_else(|| { + alias_env_key(token).map(|suffix| { + split_stable_env_key(suffix) + .or_else(|| split_revisioned_env_key(suffix)) + .map_or(suffix, |(_, key)| key) + }) + }) } pub fn uses_reserved_revision_namespace(key: &str) -> bool { diff --git a/docs/how-it-works/providers/overview.mdx b/docs/how-it-works/providers/overview.mdx index fb05acbe82..c2abdecd71 100644 --- a/docs/how-it-works/providers/overview.mdx +++ b/docs/how-it-works/providers/overview.mdx @@ -447,6 +447,24 @@ The proxy resolves credential placeholders in the following parts of an HTTP req The proxy does not rewrite cookies, response content, unsupported request bodies, or WebSocket binary frames. +### Clients that parse JWT claims + +A trusted launcher can wrap an issued placeholder in a JWT-shaped alias when a +client needs local account metadata. Keep the original placeholder suffix in +the signature segment: + +```text +base64url(header).base64url(claims).OPENSHELL-RESOLVE-ENV- +``` + +For example, a placeholder beginning `openshell:resolve:env:s` becomes a JWT +whose signature begins `OPENSHELL-RESOLVE-ENV-s`, retaining the entire handle and +environment key. Use the placeholder supplied to the running workload; do not +substitute a bare environment key or manufacture a handle. OpenShell replaces +the entire alias with the resolved credential and preserves its endpoint, +authorization-epoch, and expiry checks. The claims supply local metadata only; +the alias is not a signed identity token or an upstream credential. + ### Fail-closed behavior If policy allows a request but the credential binding does not include the From eeba0e7954c0fb4d8e9e2e29d1bfa68e290eb8b3 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sat, 10 Oct 2026 01:12:03 +0000 Subject: [PATCH 04/32] refactor(runtime): confine and reduce unsafe code (#4130) * refactor(runtime): confine and reduce unsafe code Signed-off-by: Drew Newberry * fix(ci): make signal probe portable and gate Linux initialization Signed-off-by: Drew Newberry * fix(cli): handle platform-specific SSH cancellation lints Signed-off-by: Drew Newberry --------- Signed-off-by: Drew Newberry --- Cargo.lock | 8 +- crates/openshell-bootstrap/src/edge_token.rs | 47 +- crates/openshell-bootstrap/src/lib.rs | 13 +- crates/openshell-bootstrap/src/metadata.rs | 162 +- crates/openshell-bootstrap/src/mtls.rs | 25 +- crates/openshell-bootstrap/src/oidc_token.rs | 27 +- crates/openshell-bootstrap/src/paths.rs | 262 +- crates/openshell-cli/src/commands/gateway.rs | 379 +-- crates/openshell-cli/src/commands/provider.rs | 188 +- crates/openshell-cli/src/lib.rs | 2 + crates/openshell-cli/src/main.rs | 55 +- crates/openshell-cli/src/run.rs | 156 +- crates/openshell-cli/src/ssh.rs | 455 +-- crates/openshell-cli/src/test_utils.rs | 60 +- .../tests/ensure_providers_integration.rs | 216 +- crates/openshell-cli/tests/helpers/mod.rs | 60 +- .../openshell-cli/tests/mtls_integration.rs | 241 +- .../tests/provider_commands_integration.rs | 1311 ++++---- .../sandbox_create_lifecycle_integration.rs | 2284 +++++++------- .../sandbox_name_fallback_integration.rs | 104 +- crates/openshell-core/build.rs | 30 +- crates/openshell-core/src/forward.rs | 95 +- crates/openshell-core/src/lib.rs | 4 + crates/openshell-driver-vm/Cargo.toml | 7 +- .../src/allocation_tracking.rs | 59 + .../src/bin/openshell-vm-init.rs | 2 +- crates/openshell-driver-vm/src/driver.rs | 12 +- crates/openshell-driver-vm/src/lib.rs | 3 + crates/openshell-driver-vm/src/runtime.rs | 17 +- .../openshell-isolation-interface/Cargo.toml | 7 - .../openshell-isolation-interface/README.md | 8 + .../openshell-isolation-interface/src/lib.rs | 6 +- crates/openshell-sandbox-backend/build.rs | 33 +- crates/openshell-sandbox-backend/src/lib.rs | 2 + .../openshell-sandbox-backend/src/runtime.rs | 50 +- crates/openshell-sandbox/Cargo.toml | 6 +- crates/openshell-sandbox/README.md | 11 + crates/openshell-sandbox/src/boundary_exec.rs | 21 +- .../openshell-sandbox/src/boundary_server.rs | 192 +- crates/openshell-sandbox/src/delegated.rs | 8 +- crates/openshell-sandbox/src/lib.rs | 2 + .../src/linux/child_seccomp.rs | 0 .../src/linux/landlock.rs | 0 .../src/linux/mod.rs | 0 .../src/linux/proc_fd.rs | 37 +- .../src/linux/process_signal.rs | 0 .../src/linux/seccomp_notify.rs | 97 +- .../src/linux/socket_confinement.rs | 0 .../src/linux/socket_registry.rs | 62 +- .../src/linux/task_memory.rs | 78 +- .../src/linux/workload_launcher.rs | 9 +- crates/openshell-sandbox/src/main.rs | 69 +- crates/openshell-sandbox/src/main_session.rs | 11 +- .../openshell-sandbox/src/network_broker.rs | 141 +- crates/openshell-sandbox/src/perf.rs | 2 +- crates/openshell-sandbox/src/process.rs | 76 +- .../openshell-sandbox/src/provider_files.rs | 43 +- crates/openshell-sandbox/src/pty.rs | 26 +- .../src/sandbox/linux/mod.rs | 2 +- .../src/sandbox/linux/seccomp.rs | 12 +- crates/openshell-server/build.rs | 30 +- crates/openshell-server/src/cli.rs | 2735 ++++++++--------- crates/openshell-server/src/defaults.rs | 157 +- crates/openshell-server/src/lib.rs | 7 +- crates/openshell-server/src/otel_tracing.rs | 107 +- .../openshell-supervisor-network/Cargo.toml | 5 + crates/openshell-supervisor-network/README.md | 16 + .../openshell-supervisor-network/src/lib.rs | 67 +- .../src/procfs.rs | 33 +- .../openshell-supervisor-network/src/proxy.rs | 105 +- .../src/proxy/tests/compatibility.rs | 9 +- .../src/test_support.rs | 53 + .../tests/accept_fd_exhaustion.rs | 17 +- .../openshell-supervisor-process/Cargo.toml | 1 + .../src/main_session.rs | 11 +- crates/openshell-supervisor/README.md | 13 + crates/openshell-supervisor/src/lib.rs | 2 + crates/openshell-supervisor/src/main.rs | 108 +- tests/support/environment.rs | 177 ++ 79 files changed, 5465 insertions(+), 5483 deletions(-) create mode 100644 crates/openshell-driver-vm/src/allocation_tracking.rs create mode 100644 crates/openshell-isolation-interface/README.md create mode 100644 crates/openshell-sandbox/README.md rename crates/{openshell-isolation-interface => openshell-sandbox}/src/linux/child_seccomp.rs (100%) rename crates/{openshell-isolation-interface => openshell-sandbox}/src/linux/landlock.rs (100%) rename crates/{openshell-isolation-interface => openshell-sandbox}/src/linux/mod.rs (100%) rename crates/{openshell-isolation-interface => openshell-sandbox}/src/linux/proc_fd.rs (74%) rename crates/{openshell-isolation-interface => openshell-sandbox}/src/linux/process_signal.rs (100%) rename crates/{openshell-isolation-interface => openshell-sandbox}/src/linux/seccomp_notify.rs (90%) rename crates/{openshell-isolation-interface => openshell-sandbox}/src/linux/socket_confinement.rs (100%) rename crates/{openshell-isolation-interface => openshell-sandbox}/src/linux/socket_registry.rs (87%) rename crates/{openshell-isolation-interface => openshell-sandbox}/src/linux/task_memory.rs (83%) rename crates/{openshell-isolation-interface => openshell-sandbox}/src/linux/workload_launcher.rs (94%) create mode 100644 crates/openshell-supervisor-network/README.md create mode 100644 crates/openshell-supervisor-network/src/test_support.rs create mode 100644 tests/support/environment.rs diff --git a/Cargo.lock b/Cargo.lock index 35193cd8f4..407213cf88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3909,6 +3909,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", + "memoffset", ] [[package]] @@ -4674,13 +4675,10 @@ name = "openshell-isolation-interface" version = "0.0.0" dependencies = [ "async-trait", - "libc", "openshell-core", - "rustix 1.1.4", "serde", "serde_json", "sha2 0.10.9", - "socket2", "tokio", ] @@ -5091,9 +5089,11 @@ dependencies = [ "ipnet", "libc", "miette", + "nix 0.29.0", "noyalib", "openshell-binary-identity", "openshell-core", + "openshell-driver-vm", "openshell-isolation-interface", "openshell-ocsf", "openshell-otel", @@ -5106,6 +5106,7 @@ dependencies = [ "rcgen", "regorus", "reqwest 0.12.28", + "rustix 1.1.4", "rustls", "rustls-native-certs", "rustls-pemfile", @@ -5146,6 +5147,7 @@ dependencies = [ "openshell-ocsf", "rand 0.10.2", "russh", + "rustix 1.1.4", "serde_json", "sha2 0.10.9", "tempfile", diff --git a/crates/openshell-bootstrap/src/edge_token.rs b/crates/openshell-bootstrap/src/edge_token.rs index 3eee6e096b..55d94d1bfb 100644 --- a/crates/openshell-bootstrap/src/edge_token.rs +++ b/crates/openshell-bootstrap/src/edge_token.rs @@ -88,30 +88,21 @@ pub fn remove_edge_token(gateway_name: &str) -> Result<()> { mod tests { use super::*; - /// Helper: hold the shared XDG test lock, set `XDG_CONFIG_HOME` to a - /// tempdir, run `f`, then restore the original value. - #[allow(unsafe_code)] - fn with_tmp_xdg(tmp: &std::path::Path, f: F) { - let _guard = crate::XDG_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let orig = std::env::var("XDG_CONFIG_HOME").ok(); - unsafe { - std::env::set_var("XDG_CONFIG_HOME", tmp); - } - f(); - unsafe { - match orig { - Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), - None => std::env::remove_var("XDG_CONFIG_HOME"), - } - } + /// Run the assertions in a child with an isolated configuration root. + fn with_tmp_xdg(tmp: &std::path::Path, f: impl FnOnce(&std::path::Path)) { + crate::test_environment::Environment::new() + .set("XDG_CONFIG_HOME", tmp) + .remove(crate::paths::SYSTEM_GATEWAY_DIR_ENV) + .run(|| { + let root = PathBuf::from(std::env::var_os("XDG_CONFIG_HOME").unwrap()); + f(&root); + }); } #[test] fn store_and_load_edge_token_roundtrip() { let tmp = tempfile::tempdir().unwrap(); - with_tmp_xdg(tmp.path(), || { + with_tmp_xdg(tmp.path(), |_| { store_edge_token("test-gateway", "eyJhbGciOiJSUzI1NiJ9.test.sig").unwrap(); assert_eq!( load_edge_token("test-gateway"), @@ -123,7 +114,7 @@ mod tests { #[test] fn load_edge_token_returns_none_when_not_set() { let tmp = tempfile::tempdir().unwrap(); - with_tmp_xdg(tmp.path(), || { + with_tmp_xdg(tmp.path(), |_| { assert_eq!(load_edge_token("no-such-gateway"), None); }); } @@ -131,7 +122,7 @@ mod tests { #[test] fn store_edge_token_overwrites_previous() { let tmp = tempfile::tempdir().unwrap(); - with_tmp_xdg(tmp.path(), || { + with_tmp_xdg(tmp.path(), |_| { store_edge_token("c1", "token-1").unwrap(); store_edge_token("c1", "token-2").unwrap(); assert_eq!(load_edge_token("c1"), Some("token-2".to_string())); @@ -141,7 +132,7 @@ mod tests { #[test] fn remove_edge_token_deletes_file() { let tmp = tempfile::tempdir().unwrap(); - with_tmp_xdg(tmp.path(), || { + with_tmp_xdg(tmp.path(), |_| { store_edge_token("c2", "token").unwrap(); assert!(load_edge_token("c2").is_some()); remove_edge_token("c2").unwrap(); @@ -152,7 +143,7 @@ mod tests { #[test] fn remove_edge_token_noop_when_missing() { let tmp = tempfile::tempdir().unwrap(); - with_tmp_xdg(tmp.path(), || { + with_tmp_xdg(tmp.path(), |_| { // Should not error when file doesn't exist. remove_edge_token("nonexistent").unwrap(); }); @@ -161,7 +152,7 @@ mod tests { #[test] fn edge_token_paths_reject_multi_component_gateway_names() { let tmp = tempfile::tempdir().unwrap(); - with_tmp_xdg(tmp.path(), || { + with_tmp_xdg(tmp.path(), |_| { assert!(store_edge_token("../escape", "token").is_err()); assert_eq!(load_edge_token("../escape"), None); assert!(remove_edge_token("../escape").is_err()); @@ -170,7 +161,7 @@ mod tests { #[test] fn load_edge_token_trims_whitespace() { let tmp = tempfile::tempdir().unwrap(); - with_tmp_xdg(tmp.path(), || { + with_tmp_xdg(tmp.path(), |_| { // Write manually with whitespace. let path = edge_token_path("ws-gateway").unwrap(); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); @@ -182,7 +173,7 @@ mod tests { #[test] fn load_edge_token_returns_none_for_empty_file() { let tmp = tempfile::tempdir().unwrap(); - with_tmp_xdg(tmp.path(), || { + with_tmp_xdg(tmp.path(), |_| { let path = edge_token_path("empty-gateway").unwrap(); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, " \n").unwrap(); @@ -193,7 +184,7 @@ mod tests { #[test] fn load_edge_token_falls_back_to_legacy_cf_token() { let tmp = tempfile::tempdir().unwrap(); - with_tmp_xdg(tmp.path(), || { + with_tmp_xdg(tmp.path(), |_| { // Write to the legacy cf_token path. let path = legacy_token_path("legacy-gateway").unwrap(); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); @@ -210,7 +201,7 @@ mod tests { fn store_edge_token_sets_permissions() { use std::os::unix::fs::PermissionsExt; let tmp = tempfile::tempdir().unwrap(); - with_tmp_xdg(tmp.path(), || { + with_tmp_xdg(tmp.path(), |_| { store_edge_token("perm-test", "secret").unwrap(); let path = edge_token_path("perm-test").unwrap(); let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; diff --git a/crates/openshell-bootstrap/src/lib.rs b/crates/openshell-bootstrap/src/lib.rs index 36ab7a8c73..5432b83bbf 100644 --- a/crates/openshell-bootstrap/src/lib.rs +++ b/crates/openshell-bootstrap/src/lib.rs @@ -10,15 +10,6 @@ pub mod mtls; mod paths; pub mod pki; -#[cfg(test)] -use std::sync::Mutex; - -/// Shared lock for tests that mutate the process-global `XDG_CONFIG_HOME` -/// env var. All such tests in any module must hold this lock to avoid -/// concurrent clobbering. -#[cfg(test)] -pub(crate) static XDG_TEST_LOCK: Mutex<()> = Mutex::new(()); - pub use crate::metadata::{ GatewayMetadata, GatewayMetadataSource, ListedGateway, clear_active_gateway, clear_last_sandbox_if_matches, extract_host_from_ssh_destination, gateway_metadata_source, @@ -26,3 +17,7 @@ pub use crate::metadata::{ load_gateway_metadata, load_last_sandbox, load_user_active_gateway, remove_gateway_metadata, resolve_ssh_hostname, save_active_gateway, save_last_sandbox, store_gateway_metadata, }; + +#[cfg(test)] +#[path = "../../../tests/support/environment.rs"] +mod test_environment; diff --git a/crates/openshell-bootstrap/src/metadata.rs b/crates/openshell-bootstrap/src/metadata.rs index 8190fac829..ca4ab385b6 100644 --- a/crates/openshell-bootstrap/src/metadata.rs +++ b/crates/openshell-bootstrap/src/metadata.rs @@ -498,36 +498,21 @@ mod tests { // ── last-sandbox persistence ────────────────────────────────────── - /// Helper: hold the shared XDG test lock, set `XDG_CONFIG_HOME` to a - /// tempdir, run `f`, then restore the original value. - #[allow(unsafe_code)] - fn with_tmp_xdg(tmp: &Path, f: F) { - let _guard = crate::XDG_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok(); - let orig_sys = std::env::var(crate::paths::SYSTEM_GATEWAY_DIR_ENV).ok(); - unsafe { - std::env::set_var("XDG_CONFIG_HOME", tmp); - std::env::remove_var(crate::paths::SYSTEM_GATEWAY_DIR_ENV); - } - f(); - unsafe { - match orig_xdg { - Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), - None => std::env::remove_var("XDG_CONFIG_HOME"), - } - match orig_sys { - Some(v) => std::env::set_var(crate::paths::SYSTEM_GATEWAY_DIR_ENV, v), - None => std::env::remove_var(crate::paths::SYSTEM_GATEWAY_DIR_ENV), - } - } + /// Run the assertions in a child with an isolated configuration root. + fn with_tmp_xdg(tmp: &Path, f: impl FnOnce(&Path)) { + crate::test_environment::Environment::new() + .set("XDG_CONFIG_HOME", tmp) + .remove(crate::paths::SYSTEM_GATEWAY_DIR_ENV) + .run(|| { + let root = PathBuf::from(std::env::var_os("XDG_CONFIG_HOME").unwrap()); + f(&root); + }); } #[test] fn save_and_load_last_sandbox_roundtrip() { let tmp = tempfile::tempdir().unwrap(); - with_tmp_xdg(tmp.path(), || { + with_tmp_xdg(tmp.path(), |_| { save_last_sandbox("mygateway", "default", "dev-box").unwrap(); assert_eq!( load_last_sandbox("mygateway", "default"), @@ -539,7 +524,7 @@ mod tests { #[test] fn load_last_sandbox_returns_none_when_not_set() { let tmp = tempfile::tempdir().unwrap(); - with_tmp_xdg(tmp.path(), || { + with_tmp_xdg(tmp.path(), |_| { assert_eq!(load_last_sandbox("no-such-gateway", "default"), None); }); } @@ -547,7 +532,7 @@ mod tests { #[test] fn save_last_sandbox_overwrites_previous() { let tmp = tempfile::tempdir().unwrap(); - with_tmp_xdg(tmp.path(), || { + with_tmp_xdg(tmp.path(), |_| { save_last_sandbox("g1", "default", "first").unwrap(); save_last_sandbox("g1", "default", "second").unwrap(); assert_eq!( @@ -560,7 +545,7 @@ mod tests { #[test] fn save_last_sandbox_creates_parent_dirs() { let tmp = tempfile::tempdir().unwrap(); - with_tmp_xdg(tmp.path(), || { + with_tmp_xdg(tmp.path(), |_| { save_last_sandbox("brand-new-gateway", "default", "sb1").unwrap(); assert_eq!( load_last_sandbox("brand-new-gateway", "default"), @@ -572,7 +557,7 @@ mod tests { #[test] fn load_last_sandbox_returns_none_for_legacy_single_line_file() { let tmp = tempfile::tempdir().unwrap(); - with_tmp_xdg(tmp.path(), || { + with_tmp_xdg(tmp.path(), |_| { let path = last_sandbox_path("ws-gateway").unwrap(); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, " my-sb \n").unwrap(); @@ -583,7 +568,7 @@ mod tests { #[test] fn load_last_sandbox_returns_none_for_empty_file() { let tmp = tempfile::tempdir().unwrap(); - with_tmp_xdg(tmp.path(), || { + with_tmp_xdg(tmp.path(), |_| { let path = last_sandbox_path("empty-gateway").unwrap(); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, " \n").unwrap(); @@ -594,7 +579,7 @@ mod tests { #[test] fn last_sandbox_is_per_gateway() { let tmp = tempfile::tempdir().unwrap(); - with_tmp_xdg(tmp.path(), || { + with_tmp_xdg(tmp.path(), |_| { save_last_sandbox("gateway-a", "default", "sandbox-a").unwrap(); save_last_sandbox("gateway-b", "default", "sandbox-b").unwrap(); assert_eq!( @@ -611,7 +596,7 @@ mod tests { #[test] fn last_sandbox_is_workspace_scoped() { let tmp = tempfile::tempdir().unwrap(); - with_tmp_xdg(tmp.path(), || { + with_tmp_xdg(tmp.path(), |_| { save_last_sandbox("gw", "alpha", "sb-alpha").unwrap(); assert_eq!( load_last_sandbox("gw", "alpha"), @@ -623,31 +608,18 @@ mod tests { // ── system gateway dir fallback ─────────────────────────────────── - /// Helper: hold the shared XDG test lock, point `XDG_CONFIG_HOME` at + /// Run the assertions in a child with isolated user and system roots. /// `user` and `OPENSHELL_SYSTEM_GATEWAY_DIR` at the system config root, - /// run `f`, then restore both env vars. - #[allow(unsafe_code)] - fn with_tmp_xdg_and_system(user: &Path, system: &Path, f: F) { - let _guard = crate::XDG_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok(); - let orig_sys = std::env::var(crate::paths::SYSTEM_GATEWAY_DIR_ENV).ok(); - unsafe { - std::env::set_var("XDG_CONFIG_HOME", user); - std::env::set_var(crate::paths::SYSTEM_GATEWAY_DIR_ENV, system); - } - f(); - unsafe { - match orig_xdg { - Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), - None => std::env::remove_var("XDG_CONFIG_HOME"), - } - match orig_sys { - Some(v) => std::env::set_var(crate::paths::SYSTEM_GATEWAY_DIR_ENV, v), - None => std::env::remove_var(crate::paths::SYSTEM_GATEWAY_DIR_ENV), - } - } + fn with_tmp_xdg_and_system(user: &Path, system: &Path, f: impl FnOnce(&Path, &Path)) { + crate::test_environment::Environment::new() + .set("XDG_CONFIG_HOME", user) + .set(crate::paths::SYSTEM_GATEWAY_DIR_ENV, system) + .run(|| { + let user = PathBuf::from(std::env::var_os("XDG_CONFIG_HOME").unwrap()); + let system = + PathBuf::from(std::env::var_os(crate::paths::SYSTEM_GATEWAY_DIR_ENV).unwrap()); + f(&user, &system); + }); } /// Write a `//metadata.json` file for the given endpoint. @@ -670,8 +642,8 @@ mod tests { fn system_gateway_last_sandbox_persists_in_user_config_without_shadowing() { let user = tempfile::tempdir().unwrap(); let system = tempfile::tempdir().unwrap(); - with_tmp_xdg_and_system(user.path(), system.path(), || { - write_system_metadata(&system.path().join("gateways"), "shared", "https://system"); + with_tmp_xdg_and_system(user.path(), system.path(), |_, system| { + write_system_metadata(&system.join("gateways"), "shared", "https://system"); save_last_sandbox("shared", "default", "sb-123").unwrap(); @@ -702,8 +674,8 @@ mod tests { fn system_gateway_last_sandbox_creates_user_parent_dir_without_metadata() { let user = tempfile::tempdir().unwrap(); let system = tempfile::tempdir().unwrap(); - with_tmp_xdg_and_system(user.path(), system.path(), || { - write_system_metadata(&system.path().join("gateways"), "shared", "https://system"); + with_tmp_xdg_and_system(user.path(), system.path(), |_, system| { + write_system_metadata(&system.join("gateways"), "shared", "https://system"); let user_gateway_dir = user_gateway_metadata_path("shared") .unwrap() @@ -731,8 +703,8 @@ mod tests { fn clearing_system_gateway_last_sandbox_keeps_system_metadata_visible() { let user = tempfile::tempdir().unwrap(); let system = tempfile::tempdir().unwrap(); - with_tmp_xdg_and_system(user.path(), system.path(), || { - write_system_metadata(&system.path().join("gateways"), "shared", "https://system"); + with_tmp_xdg_and_system(user.path(), system.path(), |_, system| { + write_system_metadata(&system.join("gateways"), "shared", "https://system"); save_last_sandbox("shared", "default", "sb-123").unwrap(); clear_last_sandbox_if_matches("shared", "default", "sb-123"); @@ -749,8 +721,8 @@ mod tests { fn load_user_active_gateway_does_not_fall_back_to_system_dir() { let user = tempfile::tempdir().unwrap(); let system = tempfile::tempdir().unwrap(); - with_tmp_xdg_and_system(user.path(), system.path(), || { - std::fs::write(system.path().join("active_gateway"), "from-system").unwrap(); + with_tmp_xdg_and_system(user.path(), system.path(), |_, system| { + std::fs::write(system.join("active_gateway"), "from-system").unwrap(); assert_eq!(load_user_active_gateway(), None); }); } @@ -758,8 +730,8 @@ mod tests { fn load_active_gateway_falls_back_to_system_dir() { let user = tempfile::tempdir().unwrap(); let system = tempfile::tempdir().unwrap(); - with_tmp_xdg_and_system(user.path(), system.path(), || { - std::fs::write(system.path().join("active_gateway"), "from-system").unwrap(); + with_tmp_xdg_and_system(user.path(), system.path(), |_, system| { + std::fs::write(system.join("active_gateway"), "from-system").unwrap(); assert_eq!(load_active_gateway(), Some("from-system".to_string())); }); } @@ -768,9 +740,9 @@ mod tests { fn load_active_gateway_prefers_user_over_system() { let user = tempfile::tempdir().unwrap(); let system = tempfile::tempdir().unwrap(); - with_tmp_xdg_and_system(user.path(), system.path(), || { + with_tmp_xdg_and_system(user.path(), system.path(), |_, system| { save_active_gateway("from-user").unwrap(); - std::fs::write(system.path().join("active_gateway"), "from-system").unwrap(); + std::fs::write(system.join("active_gateway"), "from-system").unwrap(); assert_eq!(load_active_gateway(), Some("from-user".to_string())); }); } @@ -779,12 +751,8 @@ mod tests { fn load_gateway_metadata_falls_back_to_system_dir() { let user = tempfile::tempdir().unwrap(); let system = tempfile::tempdir().unwrap(); - with_tmp_xdg_and_system(user.path(), system.path(), || { - write_system_metadata( - &system.path().join("gateways"), - "sys-gw", - "unix:///tmp/sys.sock", - ); + with_tmp_xdg_and_system(user.path(), system.path(), |_, system| { + write_system_metadata(&system.join("gateways"), "sys-gw", "unix:///tmp/sys.sock"); let meta = load_gateway_metadata("sys-gw").unwrap(); assert_eq!(meta.name, "sys-gw"); assert_eq!(meta.gateway_endpoint, "unix:///tmp/sys.sock"); @@ -795,12 +763,8 @@ mod tests { fn gateway_metadata_source_reports_user_system_and_missing() { let user = tempfile::tempdir().unwrap(); let system = tempfile::tempdir().unwrap(); - with_tmp_xdg_and_system(user.path(), system.path(), || { - write_system_metadata( - &system.path().join("gateways"), - "sys-gw", - "unix:///tmp/sys.sock", - ); + with_tmp_xdg_and_system(user.path(), system.path(), |_, system| { + write_system_metadata(&system.join("gateways"), "sys-gw", "unix:///tmp/sys.sock"); assert_eq!( gateway_metadata_source("sys-gw").unwrap(), Some(GatewayMetadataSource::System) @@ -825,16 +789,16 @@ mod tests { fn load_gateway_metadata_error_mentions_both_search_paths() { let user = tempfile::tempdir().unwrap(); let system = tempfile::tempdir().unwrap(); - with_tmp_xdg_and_system(user.path(), system.path(), || { + with_tmp_xdg_and_system(user.path(), system.path(), |user, system| { let err = load_gateway_metadata("missing").unwrap_err(); let msg = err.to_string(); assert!(msg.contains("missing"), "expected name in error: {msg}"); assert!( - msg.contains(user.path().to_str().unwrap()), + msg.contains(user.to_str().unwrap()), "expected user path in error: {msg}" ); assert!( - msg.contains(system.path().to_str().unwrap()), + msg.contains(system.to_str().unwrap()), "expected system path in error: {msg}" ); }); @@ -844,7 +808,7 @@ mod tests { fn load_gateway_metadata_prefers_user_over_system() { let user = tempfile::tempdir().unwrap(); let system = tempfile::tempdir().unwrap(); - with_tmp_xdg_and_system(user.path(), system.path(), || { + with_tmp_xdg_and_system(user.path(), system.path(), |_, system| { let user_meta = GatewayMetadata { name: "shared".to_string(), gateway_endpoint: "https://user-endpoint".to_string(), @@ -852,7 +816,7 @@ mod tests { }; store_gateway_metadata("shared", &user_meta).unwrap(); write_system_metadata( - &system.path().join("gateways"), + &system.join("gateways"), "shared", "https://system-endpoint", ); @@ -865,14 +829,14 @@ mod tests { fn list_gateways_merges_user_and_system() { let user = tempfile::tempdir().unwrap(); let system = tempfile::tempdir().unwrap(); - with_tmp_xdg_and_system(user.path(), system.path(), || { + with_tmp_xdg_and_system(user.path(), system.path(), |_, system| { let user_meta = GatewayMetadata { name: "alpha".to_string(), gateway_endpoint: "https://alpha".to_string(), ..Default::default() }; store_gateway_metadata("alpha", &user_meta).unwrap(); - write_system_metadata(&system.path().join("gateways"), "beta", "https://beta"); + write_system_metadata(&system.join("gateways"), "beta", "https://beta"); let gateways = list_gateways_with_source().unwrap(); assert_eq!(gateways.len(), 2); assert_eq!(gateways[0].metadata.name, "alpha"); @@ -886,18 +850,14 @@ mod tests { fn list_gateways_user_shadows_system_on_collision() { let user = tempfile::tempdir().unwrap(); let system = tempfile::tempdir().unwrap(); - with_tmp_xdg_and_system(user.path(), system.path(), || { + with_tmp_xdg_and_system(user.path(), system.path(), |_, system| { let user_meta = GatewayMetadata { name: "local-vm".to_string(), gateway_endpoint: "https://user-override".to_string(), ..Default::default() }; store_gateway_metadata("local-vm", &user_meta).unwrap(); - write_system_metadata( - &system.path().join("gateways"), - "local-vm", - "unix:///tmp/sys.sock", - ); + write_system_metadata(&system.join("gateways"), "local-vm", "unix:///tmp/sys.sock"); let gateways = list_gateways_with_source().unwrap(); assert_eq!(gateways.len(), 1); assert_eq!( @@ -912,12 +872,12 @@ mod tests { fn list_gateways_invalid_user_entry_still_shadows_system() { let user = tempfile::tempdir().unwrap(); let system = tempfile::tempdir().unwrap(); - with_tmp_xdg_and_system(user.path(), system.path(), || { + with_tmp_xdg_and_system(user.path(), system.path(), |_, system| { let user_metadata_path = user_gateway_metadata_path("shared").unwrap(); std::fs::create_dir_all(user_metadata_path.parent().unwrap()).unwrap(); std::fs::write(&user_metadata_path, "{not-json").unwrap(); - write_system_metadata(&system.path().join("gateways"), "shared", "https://system"); + write_system_metadata(&system.join("gateways"), "shared", "https://system"); let gateways = list_gateways_with_source().unwrap(); assert!(gateways.is_empty()); @@ -927,7 +887,7 @@ mod tests { fn list_gateways_empty_user_dir_does_not_hide_system() { let user = tempfile::tempdir().unwrap(); let system = tempfile::tempdir().unwrap(); - with_tmp_xdg_and_system(user.path(), system.path(), || { + with_tmp_xdg_and_system(user.path(), system.path(), |_, system| { let user_meta = GatewayMetadata { name: "shared".to_string(), gateway_endpoint: "https://user".to_string(), @@ -944,7 +904,7 @@ mod tests { assert!(user_gateway_dir.is_dir()); assert!(!user_gateway_dir.join("metadata.json").exists()); - write_system_metadata(&system.path().join("gateways"), "shared", "https://system"); + write_system_metadata(&system.join("gateways"), "shared", "https://system"); let gateways = list_gateways_with_source().unwrap(); assert_eq!(gateways.len(), 1); @@ -957,7 +917,7 @@ mod tests { fn gateway_names_must_be_single_path_components() { let user = tempfile::tempdir().unwrap(); let system = tempfile::tempdir().unwrap(); - with_tmp_xdg_and_system(user.path(), system.path(), || { + with_tmp_xdg_and_system(user.path(), system.path(), |_, _| { let meta = GatewayMetadata { name: "shared".to_string(), gateway_endpoint: "https://example.com".to_string(), @@ -974,9 +934,9 @@ mod tests { fn load_active_gateway_ignores_invalid_user_name_and_falls_back_to_system() { let user = tempfile::tempdir().unwrap(); let system = tempfile::tempdir().unwrap(); - with_tmp_xdg_and_system(user.path(), system.path(), || { - std::fs::write(user.path().join("active_gateway"), "../escape").unwrap(); - std::fs::write(system.path().join("active_gateway"), "system-default").unwrap(); + with_tmp_xdg_and_system(user.path(), system.path(), |user, system| { + std::fs::write(user.join("active_gateway"), "../escape").unwrap(); + std::fs::write(system.join("active_gateway"), "system-default").unwrap(); assert_eq!(load_user_active_gateway(), None); assert_eq!(load_active_gateway(), Some("system-default".to_string())); diff --git a/crates/openshell-bootstrap/src/mtls.rs b/crates/openshell-bootstrap/src/mtls.rs index 7a4fe5f753..3f320fd86b 100644 --- a/crates/openshell-bootstrap/src/mtls.rs +++ b/crates/openshell-bootstrap/src/mtls.rs @@ -106,28 +106,19 @@ fn validate_cli_mtls_bundle_dir(dir: &std::path::Path) -> Result<()> { mod tests { use super::*; - #[allow(unsafe_code)] - fn with_tmp_xdg(tmp: &std::path::Path, f: F) { - let _guard = crate::XDG_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let orig = std::env::var("XDG_CONFIG_HOME").ok(); - unsafe { - std::env::set_var("XDG_CONFIG_HOME", tmp); - } - f(); - unsafe { - match orig { - Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), - None => std::env::remove_var("XDG_CONFIG_HOME"), - } - } + fn with_tmp_xdg(tmp: &std::path::Path, f: impl FnOnce(&std::path::Path)) { + crate::test_environment::Environment::new() + .set("XDG_CONFIG_HOME", tmp) + .run(|| { + let root = PathBuf::from(std::env::var_os("XDG_CONFIG_HOME").unwrap()); + f(&root); + }); } #[test] fn store_pki_bundle_rejects_multi_component_gateway_names() { let tmp = tempfile::tempdir().unwrap(); - with_tmp_xdg(tmp.path(), || { + with_tmp_xdg(tmp.path(), |_| { let bundle = PkiBundle { ca_cert_pem: "ca".to_string(), ca_key_pem: "cakey".to_string(), diff --git a/crates/openshell-bootstrap/src/oidc_token.rs b/crates/openshell-bootstrap/src/oidc_token.rs index 282d2ddeca..16988a5f9d 100644 --- a/crates/openshell-bootstrap/src/oidc_token.rs +++ b/crates/openshell-bootstrap/src/oidc_token.rs @@ -143,28 +143,19 @@ pub fn is_token_actually_expired(bundle: &OidcTokenBundle) -> bool { mod tests { use super::*; - #[allow(unsafe_code)] - fn with_tmp_xdg(tmp: &std::path::Path, f: F) { - let _guard = crate::XDG_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let orig = std::env::var("XDG_CONFIG_HOME").ok(); - unsafe { - std::env::set_var("XDG_CONFIG_HOME", tmp); - } - f(); - unsafe { - match orig { - Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), - None => std::env::remove_var("XDG_CONFIG_HOME"), - } - } + fn with_tmp_xdg(tmp: &std::path::Path, f: impl FnOnce(&std::path::Path)) { + crate::test_environment::Environment::new() + .set("XDG_CONFIG_HOME", tmp) + .run(|| { + let root = PathBuf::from(std::env::var_os("XDG_CONFIG_HOME").unwrap()); + f(&root); + }); } #[test] fn oidc_token_paths_reject_multi_component_gateway_names() { let tmp = tempfile::tempdir().unwrap(); - with_tmp_xdg(tmp.path(), || { + with_tmp_xdg(tmp.path(), |_| { let bundle = OidcTokenBundle { access_token: "token".to_string(), refresh_token: None, @@ -205,7 +196,7 @@ mod tests { #[test] fn oidc_login_prompt_marker_is_per_gateway() { let tmp = tempfile::tempdir().unwrap(); - with_tmp_xdg(tmp.path(), || { + with_tmp_xdg(tmp.path(), |_| { assert!(!oidc_login_prompt_required("alpha")); assert!(!oidc_login_prompt_required("beta")); diff --git a/crates/openshell-bootstrap/src/paths.rs b/crates/openshell-bootstrap/src/paths.rs index e5eac0c219..5039d0bd27 100644 --- a/crates/openshell-bootstrap/src/paths.rs +++ b/crates/openshell-bootstrap/src/paths.rs @@ -98,233 +98,137 @@ pub fn last_sandbox_path(gateway: &str) -> Result { #[cfg(test)] mod tests { use super::*; + use crate::test_environment::Environment; #[test] - #[allow(unsafe_code)] fn system_config_dir_defaults_to_etc_openshell() { - let _guard = crate::XDG_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let orig_sys = std::env::var(SYSTEM_GATEWAY_DIR_ENV).ok(); - unsafe { - std::env::remove_var(SYSTEM_GATEWAY_DIR_ENV); - } - assert_eq!(system_config_dir(), PathBuf::from("/etc/openshell")); - assert_eq!( - system_gateways_dir(), - PathBuf::from("/etc/openshell/gateways") - ); - assert_eq!( - system_active_gateway_path(), - PathBuf::from("/etc/openshell/active_gateway") - ); - unsafe { - match orig_sys { - Some(v) => std::env::set_var(SYSTEM_GATEWAY_DIR_ENV, v), - None => std::env::remove_var(SYSTEM_GATEWAY_DIR_ENV), - } - } + Environment::new().remove(SYSTEM_GATEWAY_DIR_ENV).run(|| { + assert_eq!(system_config_dir(), PathBuf::from("/etc/openshell")); + assert_eq!( + system_gateways_dir(), + PathBuf::from("/etc/openshell/gateways") + ); + assert_eq!( + system_active_gateway_path(), + PathBuf::from("/etc/openshell/active_gateway") + ); + }); } #[test] - #[allow(unsafe_code)] fn system_config_dir_prefers_env_override() { - let _guard = crate::XDG_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = tempfile::tempdir().unwrap(); let override_dir = tmp.path().join("openshell-system"); - let orig_sys = std::env::var(SYSTEM_GATEWAY_DIR_ENV).ok(); - unsafe { - std::env::set_var(SYSTEM_GATEWAY_DIR_ENV, &override_dir); - } - assert_eq!(system_config_dir(), override_dir); - assert_eq!( - system_gateways_dir(), - tmp.path().join("openshell-system/gateways") - ); - assert_eq!( - system_active_gateway_path(), - tmp.path().join("openshell-system/active_gateway") - ); - unsafe { - match orig_sys { - Some(v) => std::env::set_var(SYSTEM_GATEWAY_DIR_ENV, v), - None => std::env::remove_var(SYSTEM_GATEWAY_DIR_ENV), - } - } + + Environment::new() + .set(SYSTEM_GATEWAY_DIR_ENV, &override_dir) + .run(|| { + let override_dir = PathBuf::from(std::env::var_os(SYSTEM_GATEWAY_DIR_ENV).unwrap()); + assert_eq!(system_config_dir(), override_dir); + assert_eq!(system_gateways_dir(), override_dir.join("gateways")); + assert_eq!( + system_active_gateway_path(), + override_dir.join("active_gateway") + ); + }); } #[test] - #[allow(unsafe_code)] fn system_config_dir_ignores_empty_env_override() { - let _guard = crate::XDG_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let orig_sys = std::env::var(SYSTEM_GATEWAY_DIR_ENV).ok(); - unsafe { - std::env::set_var(SYSTEM_GATEWAY_DIR_ENV, ""); - } - assert_eq!(system_config_dir(), PathBuf::from("/etc/openshell")); - unsafe { - match orig_sys { - Some(v) => std::env::set_var(SYSTEM_GATEWAY_DIR_ENV, v), - None => std::env::remove_var(SYSTEM_GATEWAY_DIR_ENV), - } - } + Environment::new().set(SYSTEM_GATEWAY_DIR_ENV, "").run(|| { + assert_eq!(system_config_dir(), PathBuf::from("/etc/openshell")); + }); } #[test] - #[allow(unsafe_code)] fn system_config_dir_ignores_relative_env_override() { - let _guard = crate::XDG_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let orig_sys = std::env::var(SYSTEM_GATEWAY_DIR_ENV).ok(); - unsafe { - std::env::set_var(SYSTEM_GATEWAY_DIR_ENV, "relative/openshell-system"); - } - assert_eq!(system_config_dir(), PathBuf::from("/etc/openshell")); - unsafe { - match orig_sys { - Some(v) => std::env::set_var(SYSTEM_GATEWAY_DIR_ENV, v), - None => std::env::remove_var(SYSTEM_GATEWAY_DIR_ENV), - } - } + Environment::new() + .set(SYSTEM_GATEWAY_DIR_ENV, "relative/openshell-system") + .run(|| { + assert_eq!(system_config_dir(), PathBuf::from("/etc/openshell")); + }); } #[test] - #[allow(unsafe_code)] fn user_gateway_dir_layout() { - let _guard = crate::XDG_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = tempfile::tempdir().unwrap(); - let orig = std::env::var("XDG_CONFIG_HOME").ok(); - unsafe { - std::env::set_var("XDG_CONFIG_HOME", tmp.path()); - } - assert_eq!( - user_gateway_dir("my-gateway").unwrap(), - tmp.path().join("openshell/gateways/my-gateway") - ); - unsafe { - match orig { - Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), - None => std::env::remove_var("XDG_CONFIG_HOME"), - } - } + + Environment::new() + .set("XDG_CONFIG_HOME", tmp.path()) + .run(|| { + let tmp = PathBuf::from(std::env::var_os("XDG_CONFIG_HOME").unwrap()); + assert_eq!( + user_gateway_dir("my-gateway").unwrap(), + tmp.join("openshell/gateways/my-gateway") + ); + }); } #[test] - #[allow(unsafe_code)] fn user_gateway_dir_rejects_multi_component_gateway_names() { - let _guard = crate::XDG_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = tempfile::tempdir().unwrap(); - let orig = std::env::var("XDG_CONFIG_HOME").ok(); - unsafe { - std::env::set_var("XDG_CONFIG_HOME", tmp.path()); - } - let err = user_gateway_dir("../escape").unwrap_err(); - assert!(err.to_string().contains("single path component")); - unsafe { - match orig { - Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), - None => std::env::remove_var("XDG_CONFIG_HOME"), - } - } + + Environment::new() + .set("XDG_CONFIG_HOME", tmp.path()) + .run(|| { + let err = user_gateway_dir("../escape").unwrap_err(); + assert!(err.to_string().contains("single path component")); + }); } #[test] - #[allow(unsafe_code)] fn system_gateway_dir_layout() { - let _guard = crate::XDG_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = tempfile::tempdir().unwrap(); let override_dir = tmp.path().join("openshell-system"); - let orig_sys = std::env::var(SYSTEM_GATEWAY_DIR_ENV).ok(); - unsafe { - std::env::set_var(SYSTEM_GATEWAY_DIR_ENV, &override_dir); - } - assert_eq!( - system_gateway_dir("my-gateway").unwrap(), - override_dir.join("gateways/my-gateway") - ); - unsafe { - match orig_sys { - Some(v) => std::env::set_var(SYSTEM_GATEWAY_DIR_ENV, v), - None => std::env::remove_var(SYSTEM_GATEWAY_DIR_ENV), - } - } + + Environment::new() + .set(SYSTEM_GATEWAY_DIR_ENV, &override_dir) + .run(|| { + let override_dir = PathBuf::from(std::env::var_os(SYSTEM_GATEWAY_DIR_ENV).unwrap()); + assert_eq!( + system_gateway_dir("my-gateway").unwrap(), + override_dir.join("gateways/my-gateway") + ); + }); } #[test] - #[allow(unsafe_code)] fn system_gateway_dir_rejects_multi_component_gateway_names() { - let _guard = crate::XDG_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = tempfile::tempdir().unwrap(); let override_dir = tmp.path().join("openshell-system"); - let orig_sys = std::env::var(SYSTEM_GATEWAY_DIR_ENV).ok(); - unsafe { - std::env::set_var(SYSTEM_GATEWAY_DIR_ENV, &override_dir); - } - let err = system_gateway_dir("../escape").unwrap_err(); - assert!(err.to_string().contains("single path component")); - unsafe { - match orig_sys { - Some(v) => std::env::set_var(SYSTEM_GATEWAY_DIR_ENV, v), - None => std::env::remove_var(SYSTEM_GATEWAY_DIR_ENV), - } - } + + Environment::new() + .set(SYSTEM_GATEWAY_DIR_ENV, &override_dir) + .run(|| { + let err = system_gateway_dir("../escape").unwrap_err(); + assert!(err.to_string().contains("single path component")); + }); } #[test] - #[allow(unsafe_code)] fn last_sandbox_path_layout() { - let _guard = crate::XDG_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = tempfile::tempdir().unwrap(); - let orig = std::env::var("XDG_CONFIG_HOME").ok(); - unsafe { - std::env::set_var("XDG_CONFIG_HOME", tmp.path()); - } - let path = last_sandbox_path("my-gateway").unwrap(); - assert!( - path.ends_with("openshell/gateways/my-gateway/last_sandbox"), - "unexpected path: {path:?}" - ); - unsafe { - match orig { - Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), - None => std::env::remove_var("XDG_CONFIG_HOME"), - } - } + + Environment::new() + .set("XDG_CONFIG_HOME", tmp.path()) + .run(|| { + let path = last_sandbox_path("my-gateway").unwrap(); + assert!( + path.ends_with("openshell/gateways/my-gateway/last_sandbox"), + "unexpected path: {path:?}" + ); + }); } - #[allow(unsafe_code)] #[test] fn last_sandbox_path_rejects_multi_component_gateway_names() { - let _guard = crate::XDG_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = tempfile::tempdir().unwrap(); - let orig = std::env::var("XDG_CONFIG_HOME").ok(); - unsafe { - std::env::set_var("XDG_CONFIG_HOME", tmp.path()); - } - let err = last_sandbox_path("../escape").unwrap_err(); - assert!(err.to_string().contains("single path component")); - unsafe { - match orig { - Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), - None => std::env::remove_var("XDG_CONFIG_HOME"), - } - } + + Environment::new() + .set("XDG_CONFIG_HOME", tmp.path()) + .run(|| { + let err = last_sandbox_path("../escape").unwrap_err(); + assert!(err.to_string().contains("single path component")); + }); } } diff --git a/crates/openshell-cli/src/commands/gateway.rs b/crates/openshell-cli/src/commands/gateway.rs index 07bd31cb47..ffb7dae76e 100644 --- a/crates/openshell-cli/src/commands/gateway.rs +++ b/crates/openshell-cli/src/commands/gateway.rs @@ -1593,7 +1593,7 @@ mod tests { plaintext_gateway_is_remote, }; use crate::TEST_ENV_LOCK; - use crate::test_utils::{EnvVarGuard, with_tmp_xdg}; + use crate::test_utils::{Environment, with_tmp_xdg, with_tmp_xdg_env}; use hyper::StatusCode; use openshell_bootstrap::{ GatewayMetadata, GatewayMetadataSource, ListedGateway, load_active_gateway, @@ -1606,21 +1606,20 @@ mod tests { use std::thread; use tonic::Status; - fn with_tmp_xdg_and_system(tmp: &Path, system: &Path, f: F) { - let _guard = TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let xdg_guard = EnvVarGuard::set( - "XDG_CONFIG_HOME", - tmp.to_str().expect("temp path should be utf-8"), - ); - let system_guard = EnvVarGuard::set( - "OPENSHELL_SYSTEM_GATEWAY_DIR", - system.to_str().expect("system path should be utf-8"), - ); - f(); - drop(system_guard); - drop(xdg_guard); + fn with_tmp_xdg_and_system( + tmp: &Path, + system: &Path, + env: Environment, + f: impl FnOnce(&Path, &Path), + ) { + env.set("XDG_CONFIG_HOME", tmp) + .set("OPENSHELL_SYSTEM_GATEWAY_DIR", system) + .run(|| { + let user = PathBuf::from(std::env::var_os("XDG_CONFIG_HOME").unwrap()); + let system = + PathBuf::from(std::env::var_os("OPENSHELL_SYSTEM_GATEWAY_DIR").unwrap()); + f(&user, &system); + }); } fn edge_registration(name: &str, endpoint: &str) -> GatewayMetadata { @@ -1706,7 +1705,7 @@ mod tests { #[test] fn gateway_select_uses_explicit_name_without_prompting() { let tmpdir = tempfile::tempdir().expect("create tmpdir"); - with_tmp_xdg(tmpdir.path(), || { + with_tmp_xdg(tmpdir.path(), |_| { store_gateway_metadata( "alpha", &edge_registration("alpha", "https://alpha.example.com"), @@ -1730,18 +1729,22 @@ mod tests { let _guard = TEST_ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let _env = EnvVarGuard::set("OPENSHELL_GATEWAY", "openshell"); - let warning = gateway_env_override_warning("docker-dev").expect("env override should warn"); + Environment::new() + .set("OPENSHELL_GATEWAY", "openshell") + .run(|| { + let warning = + gateway_env_override_warning("docker-dev").expect("env override should warn"); - assert!( - warning.contains("OPENSHELL_GATEWAY=openshell"), - "warning should name the overriding env var: {warning}" - ); - assert!( - warning.contains("export OPENSHELL_GATEWAY=docker-dev"), - "warning should suggest updating the env var: {warning}" - ); + assert!( + warning.contains("OPENSHELL_GATEWAY=openshell"), + "warning should name the overriding env var: {warning}" + ); + assert!( + warning.contains("export OPENSHELL_GATEWAY=docker-dev"), + "warning should suggest updating the env var: {warning}" + ); + }); } #[test] @@ -1749,15 +1752,18 @@ mod tests { let _guard = TEST_ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let _env = EnvVarGuard::set("OPENSHELL_GATEWAY", "docker-dev"); - assert_eq!(gateway_env_override_warning("docker-dev"), None); + Environment::new() + .set("OPENSHELL_GATEWAY", "docker-dev") + .run(|| { + assert_eq!(gateway_env_override_warning("docker-dev"), None); + }); } #[test] fn gateway_select_prefers_active_gateway_as_default_choice() { let tmpdir = tempfile::tempdir().expect("create tmpdir"); - with_tmp_xdg(tmpdir.path(), || { + with_tmp_xdg(tmpdir.path(), |_| { store_gateway_metadata( "alpha", &edge_registration("alpha", "https://alpha.example.com"), @@ -1785,7 +1791,7 @@ mod tests { #[test] fn gateway_select_non_interactive_lists_gateways_without_prompting() { let tmpdir = tempfile::tempdir().expect("create tmpdir"); - with_tmp_xdg(tmpdir.path(), || { + with_tmp_xdg(tmpdir.path(), |_| { store_gateway_metadata( "alpha", &edge_registration("alpha", "https://alpha.example.com"), @@ -1967,12 +1973,15 @@ mod tests { let _guard = TEST_ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let _tls_dir = EnvVarGuard::set("OPENSHELL_LOCAL_TLS_DIR", "/tmp/openshell-test-tls"); - assert_eq!( - package_managed_tls_dirs(), - vec![PathBuf::from("/tmp/openshell-test-tls")], - ); + Environment::new() + .set("OPENSHELL_LOCAL_TLS_DIR", "/tmp/openshell-test-tls") + .run(|| { + assert_eq!( + package_managed_tls_dirs(), + vec![PathBuf::from("/tmp/openshell-test-tls")], + ); + }); } #[test] @@ -1984,28 +1993,30 @@ mod tests { fs::write(package_tls.join("client/tls.crt"), "client cert").expect("write cert"); fs::write(package_tls.join("client/tls.key"), "client key").expect("write key"); - with_tmp_xdg(tmpdir.path(), || { - let _tls_dir = EnvVarGuard::set( - "OPENSHELL_LOCAL_TLS_DIR", - package_tls.to_str().expect("temp path should be utf-8"), - ); + with_tmp_xdg_env( + tmpdir.path(), + Environment::new().set("OPENSHELL_LOCAL_TLS_DIR", &package_tls), + |tmpdir| { + let package_tls = + PathBuf::from(std::env::var_os("OPENSHELL_LOCAL_TLS_DIR").unwrap()); - let imported = - import_local_package_mtls_bundle("openshell").expect("import local bundle"); + let imported = + import_local_package_mtls_bundle("openshell").expect("import local bundle"); - assert_eq!(imported.as_deref(), Some(package_tls.as_path())); + assert_eq!(imported.as_deref(), Some(package_tls.as_path())); - let mtls = tmpdir.path().join("openshell/gateways/openshell/mtls"); - assert_eq!(fs::read_to_string(mtls.join("ca.crt")).unwrap(), "ca"); - assert_eq!( - fs::read_to_string(mtls.join("tls.crt")).unwrap(), - "client cert", - ); - assert_eq!( - fs::read_to_string(mtls.join("tls.key")).unwrap(), - "client key", - ); - }); + let mtls = tmpdir.join("openshell/gateways/openshell/mtls"); + assert_eq!(fs::read_to_string(mtls.join("ca.crt")).unwrap(), "ca"); + assert_eq!( + fs::read_to_string(mtls.join("tls.crt")).unwrap(), + "client cert", + ); + assert_eq!( + fs::read_to_string(mtls.join("tls.key")).unwrap(), + "client key", + ); + }, + ); } #[test] @@ -2017,7 +2028,7 @@ mod tests { fs::write(mtls.join("tls.crt"), "client cert").expect("write cert"); fs::write(mtls.join("tls.key"), "client key").expect("write key"); - with_tmp_xdg(tmpdir.path(), || { + with_tmp_xdg(tmpdir.path(), |_| { assert!(mtls_certs_exist_for_gateway("k8s")); assert!(!mtls_certs_exist_for_gateway("openshell")); }); @@ -2059,7 +2070,7 @@ mod tests { #[test] fn gateway_add_registers_plaintext_loopback_gateway_without_local_flag() { let tmpdir = tempfile::tempdir().expect("create tmpdir"); - with_tmp_xdg(tmpdir.path(), || { + with_tmp_xdg(tmpdir.path(), |_| { let runtime = tokio::runtime::Runtime::new().expect("create runtime"); runtime.block_on(async { gateway_add( @@ -2090,7 +2101,7 @@ mod tests { #[test] fn gateway_add_respects_local_flag_for_plaintext_registrations() { let tmpdir = tempfile::tempdir().expect("create tmpdir"); - with_tmp_xdg(tmpdir.path(), || { + with_tmp_xdg(tmpdir.path(), |_| { let runtime = tokio::runtime::Runtime::new().expect("create runtime"); runtime.block_on(async { gateway_add( @@ -2147,7 +2158,7 @@ mod tests { #[test] fn gateway_add_oidc_rolls_back_on_auth_failure() { let tmpdir = tempfile::tempdir().expect("create tmpdir"); - with_tmp_xdg(tmpdir.path(), || { + with_tmp_xdg(tmpdir.path(), |_| { let runtime = tokio::runtime::Runtime::new().expect("create runtime"); // Register a working plaintext gateway first so we can verify @@ -2204,139 +2215,153 @@ mod tests { fn gateway_add_oidc_rollback_keeps_system_active_fallback_userless() { let user = tempfile::tempdir().expect("create user tmpdir"); let system = tempfile::tempdir().expect("create system tmpdir"); - with_tmp_xdg_and_system(user.path(), system.path(), || { - fs::write(system.path().join("active_gateway"), "system-default") - .expect("write system active gateway"); - assert_eq!(load_user_active_gateway(), None); - assert_eq!(load_active_gateway().as_deref(), Some("system-default")); + with_tmp_xdg_and_system( + user.path(), + system.path(), + Environment::new(), + |_, system| { + fs::write(system.join("active_gateway"), "system-default") + .expect("write system active gateway"); + assert_eq!(load_user_active_gateway(), None); + assert_eq!(load_active_gateway().as_deref(), Some("system-default")); + + let runtime = tokio::runtime::Runtime::new().expect("create runtime"); + runtime.block_on(async { + gateway_add( + "https://gateway.example.com", + Some("oidc-fail"), + None, + false, + Some("http://127.0.0.1:1/realms/nonexistent"), + "openshell-cli", + None, + None, + false, + ) + .await + .expect("gateway_add should not return Err on auth failure"); + }); - let runtime = tokio::runtime::Runtime::new().expect("create runtime"); - runtime.block_on(async { - gateway_add( - "https://gateway.example.com", - Some("oidc-fail"), - None, - false, - Some("http://127.0.0.1:1/realms/nonexistent"), - "openshell-cli", - None, + assert!( + load_gateway_metadata("oidc-fail").is_err(), + "failed OIDC gateway should be removed after auth failure" + ); + assert_eq!( + load_user_active_gateway(), None, - false, - ) - .await - .expect("gateway_add should not return Err on auth failure"); - }); - - assert!( - load_gateway_metadata("oidc-fail").is_err(), - "failed OIDC gateway should be removed after auth failure" - ); - assert_eq!( - load_user_active_gateway(), - None, - "rollback should not persist the system fallback into user config" - ); - assert_eq!(load_active_gateway().as_deref(), Some("system-default")); - }); + "rollback should not persist the system fallback into user config" + ); + assert_eq!(load_active_gateway().as_deref(), Some("system-default")); + }, + ); } #[test] fn gateway_add_cloud_rolls_back_on_auth_failure() { let tmpdir = tempfile::tempdir().expect("create tmpdir"); - with_tmp_xdg(tmpdir.path(), || { - let _no_browser = EnvVarGuard::set("OPENSHELL_NO_BROWSER", "0"); - let _browser_auth_failure = EnvVarGuard::set("OPENSHELL_TEST_BROWSER_AUTH_FAIL", "1"); - let runtime = tokio::runtime::Runtime::new().expect("create runtime"); - - // Register a working plaintext gateway first. - runtime.block_on(async { - gateway_add( - "http://127.0.0.1:9999", + with_tmp_xdg_env( + tmpdir.path(), + Environment::new() + .set("OPENSHELL_NO_BROWSER", "0") + .set("OPENSHELL_TEST_BROWSER_AUTH_FAIL", "1"), + |_| { + let runtime = tokio::runtime::Runtime::new().expect("create runtime"); + + // Register a working plaintext gateway first. + runtime.block_on(async { + gateway_add( + "http://127.0.0.1:9999", + Some("existing-gw"), + None, + false, + None, + "openshell-cli", + None, + None, + false, + ) + .await + .expect("register seed gateway"); + }); + assert_eq!(load_active_gateway().as_deref(), Some("existing-gw")); + + // Attempt cloud gateway add. Keep browser suppression disabled so + // auth failure still rolls back the registration, but use the + // test-only auth failure hook instead of opening the OS browser. + runtime.block_on(async { + gateway_add( + "https://127.0.0.1:1", + Some("cloud-fail"), + None, + false, + None, + "openshell-cli", + None, + None, + false, + ) + .await + .expect("gateway_add should not return Err on auth failure"); + }); + + // The failed registration should have been rolled back. + assert!( + load_gateway_metadata("cloud-fail").is_err(), + "failed cloud gateway should be removed after auth failure" + ); + assert_eq!( + load_active_gateway().as_deref(), Some("existing-gw"), - None, - false, - None, - "openshell-cli", - None, - None, - false, - ) - .await - .expect("register seed gateway"); - }); - assert_eq!(load_active_gateway().as_deref(), Some("existing-gw")); - - // Attempt cloud gateway add. Keep browser suppression disabled so - // auth failure still rolls back the registration, but use the - // test-only auth failure hook instead of opening the OS browser. - runtime.block_on(async { - gateway_add( - "https://127.0.0.1:1", - Some("cloud-fail"), - None, - false, - None, - "openshell-cli", - None, - None, - false, - ) - .await - .expect("gateway_add should not return Err on auth failure"); - }); - - // The failed registration should have been rolled back. - assert!( - load_gateway_metadata("cloud-fail").is_err(), - "failed cloud gateway should be removed after auth failure" - ); - assert_eq!( - load_active_gateway().as_deref(), - Some("existing-gw"), - "active gateway should be restored after rollback" - ); - }); + "active gateway should be restored after rollback" + ); + }, + ); } #[test] fn gateway_add_cloud_rollback_keeps_system_active_fallback_userless() { let user = tempfile::tempdir().expect("create user tmpdir"); let system = tempfile::tempdir().expect("create system tmpdir"); - with_tmp_xdg_and_system(user.path(), system.path(), || { - let _no_browser = EnvVarGuard::set("OPENSHELL_NO_BROWSER", "0"); - let _browser_auth_failure = EnvVarGuard::set("OPENSHELL_TEST_BROWSER_AUTH_FAIL", "1"); - fs::write(system.path().join("active_gateway"), "system-default") - .expect("write system active gateway"); - assert_eq!(load_user_active_gateway(), None); - assert_eq!(load_active_gateway().as_deref(), Some("system-default")); + with_tmp_xdg_and_system( + user.path(), + system.path(), + Environment::new() + .set("OPENSHELL_NO_BROWSER", "0") + .set("OPENSHELL_TEST_BROWSER_AUTH_FAIL", "1"), + |_, system| { + fs::write(system.join("active_gateway"), "system-default") + .expect("write system active gateway"); + assert_eq!(load_user_active_gateway(), None); + assert_eq!(load_active_gateway().as_deref(), Some("system-default")); + + let runtime = tokio::runtime::Runtime::new().expect("create runtime"); + runtime.block_on(async { + gateway_add( + "https://127.0.0.1:1", + Some("cloud-fail"), + None, + false, + None, + "openshell-cli", + None, + None, + false, + ) + .await + .expect("gateway_add should not return Err on auth failure"); + }); - let runtime = tokio::runtime::Runtime::new().expect("create runtime"); - runtime.block_on(async { - gateway_add( - "https://127.0.0.1:1", - Some("cloud-fail"), - None, - false, - None, - "openshell-cli", - None, + assert!( + load_gateway_metadata("cloud-fail").is_err(), + "failed cloud gateway should be removed after auth failure" + ); + assert_eq!( + load_user_active_gateway(), None, - false, - ) - .await - .expect("gateway_add should not return Err on auth failure"); - }); - - assert!( - load_gateway_metadata("cloud-fail").is_err(), - "failed cloud gateway should be removed after auth failure" - ); - assert_eq!( - load_user_active_gateway(), - None, - "rollback should not persist the system fallback into user config" - ); - assert_eq!(load_active_gateway().as_deref(), Some("system-default")); - }); + "rollback should not persist the system fallback into user config" + ); + assert_eq!(load_active_gateway().as_deref(), Some("system-default")); + }, + ); } #[test] fn status_to_json_connected() { diff --git a/crates/openshell-cli/src/commands/provider.rs b/crates/openshell-cli/src/commands/provider.rs index 05ec158e42..bb3f9ad386 100644 --- a/crates/openshell-cli/src/commands/provider.rs +++ b/crates/openshell-cli/src/commands/provider.rs @@ -2870,7 +2870,7 @@ pub async fn provider_delete( mod tests { use super::*; use crate::TEST_ENV_LOCK; - use crate::test_utils::EnvVarGuard; + use crate::test_utils::Environment; use std::fs; use std::io::Write; @@ -2958,30 +2958,34 @@ mod tests { let _lock = TEST_ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let _credential = EnvVarGuard::set("GITHUB_TOKEN", "test-private-token-value"); - let mut profile = openshell_providers::example_profiles::load("github").to_proto(); - profile.source = "user".to_string(); - profile.scope = "platform".to_string(); - - let rendered = - format_provider_profile_description(&profile, "table").expect("profile details render"); - assert!(rendered.starts_with("github (provider)\nCategory: SOURCE CONTROL\n")); - assert!( - rendered.contains("Display name: GitHub\nDescription: GitHub API and Git operations\n") - ); - assert!(rendered.contains("Source: user\nScope: platform\n")); - assert!(rendered.contains("Environment variables: GITHUB_TOKEN, GH_TOKEN\n")); - assert!(rendered.contains("Authentication: bearer\n")); - assert!(rendered.contains("Header: authorization\n")); - assert!(!rendered.contains("test-private-token-value")); - assert!(!rendered.contains("Default URL")); - assert!(rendered.contains("Protocol: graphql\n")); - assert!(rendered.contains("Path: /graphql\n")); - assert_eq!(rendered.matches("Access: read-only\n").count(), 2); - assert!(rendered.contains("Access: custom rules\n Rules: 4 allow, 0 deny\n")); - assert!(rendered.contains("Enforcement: enforce\n")); - assert!(rendered.contains(" /usr/bin/gh\n")); - assert!(rendered.contains(" /usr/local/bin/git\n")); + + Environment::new() + .set("GITHUB_TOKEN", "test-private-token-value") + .run(|| { + let mut profile = openshell_providers::example_profiles::load("github").to_proto(); + profile.source = "user".to_string(); + profile.scope = "platform".to_string(); + + let rendered = format_provider_profile_description(&profile, "table") + .expect("profile details render"); + assert!(rendered.starts_with("github (provider)\nCategory: SOURCE CONTROL\n")); + assert!(rendered.contains( + "Display name: GitHub\nDescription: GitHub API and Git operations\n" + )); + assert!(rendered.contains("Source: user\nScope: platform\n")); + assert!(rendered.contains("Environment variables: GITHUB_TOKEN, GH_TOKEN\n")); + assert!(rendered.contains("Authentication: bearer\n")); + assert!(rendered.contains("Header: authorization\n")); + assert!(!rendered.contains("test-private-token-value")); + assert!(!rendered.contains("Default URL")); + assert!(rendered.contains("Protocol: graphql\n")); + assert!(rendered.contains("Path: /graphql\n")); + assert_eq!(rendered.matches("Access: read-only\n").count(), 2); + assert!(rendered.contains("Access: custom rules\n Rules: 4 allow, 0 deny\n")); + assert!(rendered.contains("Enforcement: enforce\n")); + assert!(rendered.contains(" /usr/bin/gh\n")); + assert!(rendered.contains(" /usr/local/bin/git\n")); + }); } #[test] @@ -3488,15 +3492,19 @@ binaries: [/usr/bin/curl] let _lock = TEST_ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let _guard = EnvVarGuard::set( - "GOOGLE_APPLICATION_CREDENTIALS", - "/nonexistent/path/to/adc.json", - ); - let err = read_gcloud_adc().expect_err("missing file should error"); - assert!( - err.to_string().contains("failed to read gcloud ADC file"), - "unexpected error: {err}" - ); + + Environment::new() + .set( + "GOOGLE_APPLICATION_CREDENTIALS", + "/nonexistent/path/to/adc.json", + ) + .run(|| { + let err = read_gcloud_adc().expect_err("missing file should error"); + assert!( + err.to_string().contains("failed to read gcloud ADC file"), + "unexpected error: {err}" + ); + }); } #[test] @@ -3511,19 +3519,23 @@ binaries: [/usr/bin/curl] "private_key_id": "key123" }); Write::write_all(&mut tmp.as_file(), json.to_string().as_bytes()).expect("write tempfile"); - let _guard = EnvVarGuard::set( - "GOOGLE_APPLICATION_CREDENTIALS", - tmp.path().to_str().expect("tempfile path"), - ); - let err = read_gcloud_adc().expect_err("wrong type should error"); - // The service_account type gets a targeted message directing the user - // to the real Vertex service-account credential flow instead of the - // generic authorized_user hint. - assert!( - err.to_string() - .contains("GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN"), - "error should mention the service-account token key, got: {err}" - ); + + Environment::new() + .set( + "GOOGLE_APPLICATION_CREDENTIALS", + tmp.path().to_str().expect("tempfile path"), + ) + .run(|| { + let err = read_gcloud_adc().expect_err("wrong type should error"); + // The service_account type gets a targeted message directing the user + // to the real Vertex service-account credential flow instead of the + // generic authorized_user hint. + assert!( + err.to_string() + .contains("GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN"), + "error should mention the service-account token key, got: {err}" + ); + }); } #[test] @@ -3539,15 +3551,19 @@ binaries: [/usr/bin/curl] "refresh_token": "test-refresh-token" }); Write::write_all(&mut tmp.as_file(), json.to_string().as_bytes()).expect("write tempfile"); - let _guard = EnvVarGuard::set( - "GOOGLE_APPLICATION_CREDENTIALS", - tmp.path().to_str().expect("tempfile path"), - ); - let (client_id, client_secret, refresh_token) = - read_gcloud_adc().expect("valid ADC should parse"); - assert_eq!(client_id, "test-client-id.apps.googleusercontent.com"); - assert_eq!(client_secret, "test-client-secret"); - assert_eq!(refresh_token, "test-refresh-token"); + + Environment::new() + .set( + "GOOGLE_APPLICATION_CREDENTIALS", + tmp.path().to_str().expect("tempfile path"), + ) + .run(|| { + let (client_id, client_secret, refresh_token) = + read_gcloud_adc().expect("valid ADC should parse"); + assert_eq!(client_id, "test-client-id.apps.googleusercontent.com"); + assert_eq!(client_secret, "test-client-secret"); + assert_eq!(refresh_token, "test-refresh-token"); + }); } #[test] @@ -3564,15 +3580,17 @@ binaries: [/usr/bin/curl] "refresh_token": "cloudsdk-refresh-token" }); fs::write(&adc_path, json.to_string()).expect("write adc file"); - let _adc_guard = EnvVarGuard::unset("GOOGLE_APPLICATION_CREDENTIALS"); - let _cloudsdk_guard = - EnvVarGuard::set("CLOUDSDK_CONFIG", dir.path().to_str().expect("config path")); - let (client_id, client_secret, refresh_token) = - read_gcloud_adc().expect("valid CLOUDSDK_CONFIG ADC should parse"); - assert_eq!(client_id, "cloudsdk-client-id.apps.googleusercontent.com"); - assert_eq!(client_secret, "cloudsdk-client-secret"); - assert_eq!(refresh_token, "cloudsdk-refresh-token"); + Environment::new() + .remove("GOOGLE_APPLICATION_CREDENTIALS") + .set("CLOUDSDK_CONFIG", dir.path().to_str().expect("config path")) + .run(|| { + let (client_id, client_secret, refresh_token) = + read_gcloud_adc().expect("valid CLOUDSDK_CONFIG ADC should parse"); + assert_eq!(client_id, "cloudsdk-client-id.apps.googleusercontent.com"); + assert_eq!(client_secret, "cloudsdk-client-secret"); + assert_eq!(refresh_token, "cloudsdk-refresh-token"); + }); } #[test] @@ -3583,25 +3601,29 @@ binaries: [/usr/bin/curl] let tmp = tempfile::NamedTempFile::new().expect("tempfile"); Write::write_all(&mut tmp.as_file(), b"not valid json at all {{{{") .expect("write tempfile"); - let _guard = EnvVarGuard::set( - "GOOGLE_APPLICATION_CREDENTIALS", - tmp.path().to_str().expect("tempfile path"), - ); - let result = read_gcloud_adc(); - assert!( - result.is_err(), - "malformed JSON should produce an error, got: {result:?}" - ); - let err = result.unwrap_err(); - let msg = format!("{err}"); - assert!( - msg.contains("parse") - || msg.contains("JSON") - || msg.contains("json") - || msg.contains("invalid") - || msg.contains("failed"), - "error message should mention parse/JSON failure, got: {msg}" - ); + + Environment::new() + .set( + "GOOGLE_APPLICATION_CREDENTIALS", + tmp.path().to_str().expect("tempfile path"), + ) + .run(|| { + let result = read_gcloud_adc(); + assert!( + result.is_err(), + "malformed JSON should produce an error, got: {result:?}" + ); + let err = result.unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("parse") + || msg.contains("JSON") + || msg.contains("json") + || msg.contains("invalid") + || msg.contains("failed"), + "error message should mention parse/JSON failure, got: {msg}" + ); + }); } #[test] diff --git a/crates/openshell-cli/src/lib.rs b/crates/openshell-cli/src/lib.rs index 910920f42a..c7801f8191 100644 --- a/crates/openshell-cli/src/lib.rs +++ b/crates/openshell-cli/src/lib.rs @@ -5,6 +5,8 @@ //! //! This crate provides the CLI implementation for `OpenShell`. +#![forbid(unsafe_code)] + #[cfg(test)] pub(crate) static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); #[cfg(test)] diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index c0821ccc9f..0375b98f24 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -3,6 +3,8 @@ //! `OpenShell` CLI - command-line interface for `OpenShell`. +#![forbid(unsafe_code)] + use clap::{CommandFactory, Parser, Subcommand, ValueEnum, ValueHint}; use clap_complete::engine::ArgValueCompleter; use clap_complete::env::CompleteEnv; @@ -2617,22 +2619,12 @@ async fn run_async() -> Result<()> { ) .init(); - // Propagate verbosity to the OpenSSH LogLevel used by SSH subprocesses. - // Only set the env var when it hasn't been explicitly overridden by the - // user, so `OPENSHELL_SSH_LOG_LEVEL=DEBUG openshell ...` still wins. - if std::env::var("OPENSHELL_SSH_LOG_LEVEL").is_err() { - let ssh_log_level = match cli.verbose { - 0 => "ERROR", - 1 => "INFO", - _ => "DEBUG", - }; - // SAFETY: Called early in main() before spawning async tasks that - // read the environment, so no concurrent readers exist. - #[allow(unsafe_code)] - unsafe { - std::env::set_var("OPENSHELL_SSH_LOG_LEVEL", ssh_log_level); - } - } + // SSH commands read an explicit environment override themselves. + openshell_cli::ssh::set_default_ssh_log_level(match cli.verbose { + 0 => "ERROR", + 1 => "INFO", + _ => "DEBUG", + }); match cli.command { // ----------------------------------------------------------- @@ -4335,29 +4327,10 @@ mod tests { )); } - // Tests below mutate the process-global XDG_CONFIG_HOME env var. - // A static mutex serialises them so concurrent threads don't clobber - // each other's environment. - static XDG_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - /// Helper: hold `XDG_LOCK`, set `XDG_CONFIG_HOME` to a tempdir, run `f`, - /// then restore the original value. - #[allow(unsafe_code)] - fn with_tmp_xdg(tmp: &std::path::Path, f: F) { - let _guard = XDG_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let orig = std::env::var("XDG_CONFIG_HOME").ok(); - unsafe { - std::env::set_var("XDG_CONFIG_HOME", tmp); - } - f(); - unsafe { - match orig { - Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), - None => std::env::remove_var("XDG_CONFIG_HOME"), - } - } + fn with_tmp_xdg(tmp: &std::path::Path, f: impl FnOnce()) { + test_environment::Environment::new() + .set("XDG_CONFIG_HOME", tmp) + .run(f); } fn edge_metadata(name: &str, endpoint: &str) -> GatewayMetadata { @@ -7053,3 +7026,7 @@ mod tests { } } } + +#[cfg(test)] +#[path = "../../../tests/support/environment.rs"] +mod test_environment; diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 830ebe3540..02bc0968f0 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -6942,7 +6942,7 @@ mod tests { use crate::commands::common::{ parse_credential_expiry_pairs, parse_credential_pairs, progress_step_from_metadata, }; - use crate::test_utils::EnvVarGuard; + use crate::test_utils::Environment; use openshell_bootstrap::GatewayMetadata; use std::fs; use std::path::Path; @@ -7130,81 +7130,97 @@ mod tests { #[test] fn parse_credential_pairs_reads_value_from_environment_for_key_only_form() { - let _guard = EnvVarGuard::set("NAV_PARSE_CREDENTIAL_TEST_KEY", "from-env"); - - let parsed = - parse_credential_pairs(&["NAV_PARSE_CREDENTIAL_TEST_KEY".to_string()]).expect("parse"); - assert_eq!( - parsed.get("NAV_PARSE_CREDENTIAL_TEST_KEY"), - Some(&"from-env".to_string()) - ); + Environment::new() + .set("NAV_PARSE_CREDENTIAL_TEST_KEY", "from-env") + .run(|| { + let parsed = parse_credential_pairs(&["NAV_PARSE_CREDENTIAL_TEST_KEY".to_string()]) + .expect("parse"); + assert_eq!( + parsed.get("NAV_PARSE_CREDENTIAL_TEST_KEY"), + Some(&"from-env".to_string()) + ); + }); } #[test] fn parse_credential_pairs_rejects_missing_environment_for_key_only_form() { - let _guard = EnvVarGuard::unset("NAV_PARSE_CREDENTIAL_MISSING"); - - let err = parse_credential_pairs(&["NAV_PARSE_CREDENTIAL_MISSING".to_string()]) - .expect_err("missing env should error"); - assert!(err.to_string().contains( + Environment::new() + .remove("NAV_PARSE_CREDENTIAL_MISSING") + .run(|| { + let err = parse_credential_pairs(&["NAV_PARSE_CREDENTIAL_MISSING".to_string()]) + .expect_err("missing env should error"); + assert!(err.to_string().contains( "requires local env var 'NAV_PARSE_CREDENTIAL_MISSING' to be set to a non-empty value" )); + }); } #[test] fn parse_credential_pairs_rejects_empty_environment_for_key_only_form() { - let _guard = EnvVarGuard::set("NAV_PARSE_CREDENTIAL_EMPTY", ""); - - let err = parse_credential_pairs(&["NAV_PARSE_CREDENTIAL_EMPTY".to_string()]) - .expect_err("empty env should error"); - assert!(err.to_string().contains( + Environment::new() + .set("NAV_PARSE_CREDENTIAL_EMPTY", "") + .run(|| { + let err = parse_credential_pairs(&["NAV_PARSE_CREDENTIAL_EMPTY".to_string()]) + .expect_err("empty env should error"); + assert!(err.to_string().contains( "requires local env var 'NAV_PARSE_CREDENTIAL_EMPTY' to be set to a non-empty value" )); + }); } #[test] fn parse_secret_material_env_pairs_reads_value_from_named_environment_variable() { - let _guard = EnvVarGuard::set("NAV_PARSE_SME_NAMED", "pem-material"); - - let parsed = - parse_secret_material_env_pairs(&["private_key=NAV_PARSE_SME_NAMED".to_string()]) + Environment::new() + .set("NAV_PARSE_SME_NAMED", "pem-material") + .run(|| { + let parsed = parse_secret_material_env_pairs(&[ + "private_key=NAV_PARSE_SME_NAMED".to_string() + ]) .expect("parse"); - assert_eq!(parsed.get("private_key"), Some(&"pem-material".to_string())); + assert_eq!(parsed.get("private_key"), Some(&"pem-material".to_string())); + }); } #[test] fn parse_secret_material_env_pairs_defaults_env_name_to_key() { - let _guard = EnvVarGuard::set("NAV_PARSE_SME_KEY_ONLY", "key-only-material"); - - let parsed = parse_secret_material_env_pairs(&["NAV_PARSE_SME_KEY_ONLY".to_string()]) - .expect("parse"); - assert_eq!( - parsed.get("NAV_PARSE_SME_KEY_ONLY"), - Some(&"key-only-material".to_string()) - ); + Environment::new() + .set("NAV_PARSE_SME_KEY_ONLY", "key-only-material") + .run(|| { + let parsed = + parse_secret_material_env_pairs(&["NAV_PARSE_SME_KEY_ONLY".to_string()]) + .expect("parse"); + assert_eq!( + parsed.get("NAV_PARSE_SME_KEY_ONLY"), + Some(&"key-only-material".to_string()) + ); + }); } #[test] fn parse_secret_material_env_pairs_rejects_missing_environment() { - let _guard = EnvVarGuard::unset("NAV_PARSE_SME_MISSING"); - - let err = - parse_secret_material_env_pairs(&["private_key=NAV_PARSE_SME_MISSING".to_string()]) - .expect_err("missing env should error"); - assert!(err.to_string().contains( - "requires local env var 'NAV_PARSE_SME_MISSING' to be set to a non-empty value" - )); + Environment::new().remove("NAV_PARSE_SME_MISSING").run(|| { + let err = + parse_secret_material_env_pairs(&["private_key=NAV_PARSE_SME_MISSING".to_string()]) + .expect_err("missing env should error"); + assert!(err.to_string().contains( + "requires local env var 'NAV_PARSE_SME_MISSING' to be set to a non-empty value" + )); + }); } #[test] fn parse_secret_material_env_pairs_rejects_empty_environment_value() { - let _guard = EnvVarGuard::set("NAV_PARSE_SME_EMPTY", " "); - - let err = parse_secret_material_env_pairs(&["private_key=NAV_PARSE_SME_EMPTY".to_string()]) - .expect_err("blank env should error"); - assert!(err.to_string().contains( - "requires local env var 'NAV_PARSE_SME_EMPTY' to be set to a non-empty value" - )); + Environment::new() + .set("NAV_PARSE_SME_EMPTY", " ") + .run(|| { + let err = parse_secret_material_env_pairs(&[ + "private_key=NAV_PARSE_SME_EMPTY".to_string() + ]) + .expect_err("blank env should error"); + assert!(err.to_string().contains( + "requires local env var 'NAV_PARSE_SME_EMPTY' to be set to a non-empty value" + )); + }); } #[test] @@ -7216,17 +7232,19 @@ mod tests { #[test] fn parse_secret_material_env_pairs_rejects_duplicate_keys() { - let _guard = EnvVarGuard::set("NAV_PARSE_SME_DUP", "value"); - - let err = parse_secret_material_env_pairs(&[ - "private_key=NAV_PARSE_SME_DUP".to_string(), - "private_key=NAV_PARSE_SME_DUP".to_string(), - ]) - .expect_err("duplicate key should error"); - assert!( - err.to_string() - .contains("key 'private_key' supplied more than once") - ); + Environment::new() + .set("NAV_PARSE_SME_DUP", "value") + .run(|| { + let err = parse_secret_material_env_pairs(&[ + "private_key=NAV_PARSE_SME_DUP".to_string(), + "private_key=NAV_PARSE_SME_DUP".to_string(), + ]) + .expect_err("duplicate key should error"); + assert!( + err.to_string() + .contains("key 'private_key' supplied more than once") + ); + }); } #[test] @@ -8162,17 +8180,19 @@ mod tests { fs::write(repo.join("nested/file.txt"), "file").expect("write file.txt"); fs::write(repo.join("top.txt"), "top").expect("write top.txt"); - let _git_dir = EnvVarGuard::set("GIT_DIR", "/tmp/not-the-test-repo/.git"); - let _git_work_tree = EnvVarGuard::set("GIT_WORK_TREE", "/tmp/not-the-test-repo"); - - let result = git_sync_files(&repo.join("nested")); - let (base_dir, files) = result.expect("git_sync_files should succeed"); + Environment::new() + .set("GIT_DIR", "/tmp/not-the-test-repo/.git") + .set("GIT_WORK_TREE", "/tmp/not-the-test-repo") + .run(|| { + let result = git_sync_files(&repo.join("nested")); + let (base_dir, files) = result.expect("git_sync_files should succeed"); - assert_eq!( - base_dir, - fs::canonicalize(repo.join("nested")).expect("canonicalize nested path") - ); - assert_eq!(files, vec!["file.txt"]); + assert_eq!( + base_dir, + fs::canonicalize(repo.join("nested")).expect("canonicalize nested path") + ); + assert_eq!(files, vec!["file.txt"]); + }); } #[test] diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index 7e1cccd280..30f7146d32 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -9,7 +9,7 @@ use crate::tls::{ }; use miette::{IntoDiagnostic, Report, Result, WrapErr}; #[cfg(unix)] -use nix::sys::signal::{SaFlags, SigAction, SigHandler, SigSet, Signal, sigaction}; +use nix::sys::signal::Signal; use openshell_core::driver_mounts; use openshell_core::forward::{ ForwardSpec, build_proxy_command, format_gateway_url, resolve_ssh_gateway, shell_escape, @@ -26,6 +26,7 @@ use std::io::{IsTerminal, Write}; use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus, Stdio}; +use std::sync::OnceLock; use std::time::{Duration, Instant}; use tokio::io::AsyncWriteExt; use tokio::net::TcpStream; @@ -64,6 +65,14 @@ const SSH_TRANSPORT_FAILURE_EXIT_CODE: i32 = 255; const SYNC_RETRY_ATTEMPTS: usize = 4; const SYNC_RETRY_DELAY: Duration = Duration::from_secs(2); const UPLOAD_DESTINATION_CHANGED_EXIT_CODE: i32 = 73; +static DEFAULT_SSH_LOG_LEVEL: OnceLock<&'static str> = OnceLock::new(); + +/// Set the SSH log level derived from CLI verbosity before starting commands. +pub fn set_default_ssh_log_level(level: &'static str) { + DEFAULT_SSH_LOG_LEVEL + .set(level) + .expect("SSH log level is configured only once"); +} #[derive(Clone, Copy, Debug)] pub enum Editor { @@ -197,12 +206,18 @@ async fn ssh_session_config( } fn ssh_base_command(proxy_command: &str) -> Command { - // SSH log level follows the program's verbosity. main() maps the `-v` - // count to OPENSHELL_SSH_LOG_LEVEL; an explicit env-var override wins. - let ssh_log_level = - std::env::var("OPENSHELL_SSH_LOG_LEVEL").unwrap_or_else(|_| "ERROR".to_string()); + // An explicit environment override wins over the CLI verbosity default. + let ssh_log_level = std::env::var("OPENSHELL_SSH_LOG_LEVEL").unwrap_or_else(|_| { + DEFAULT_SSH_LOG_LEVEL + .get() + .copied() + .unwrap_or("ERROR") + .to_string() + }); let mut command = Command::new("ssh"); + // Nested ProxyCommand processes inherit the effective verbosity too. + command.env("OPENSHELL_SSH_LOG_LEVEL", &ssh_log_level); command .arg("-o") .arg(format!("ProxyCommand={proxy_command}")) @@ -225,82 +240,39 @@ fn ssh_base_command(proxy_command: &str) -> Command { command } -#[cfg(unix)] -const TRANSIENT_TTY_SIGNALS: &[Signal] = &[Signal::SIGINT, Signal::SIGQUIT, Signal::SIGTERM]; - -#[cfg(unix)] -struct ParentSignalGuard { - previous: Vec<(Signal, SigAction)>, -} - -#[cfg(unix)] -impl ParentSignalGuard { - #[allow(unsafe_code)] - fn ignore_transient_tty_signals() -> Result { - let mut previous = Vec::with_capacity(TRANSIENT_TTY_SIGNALS.len()); - for &signal in TRANSIENT_TTY_SIGNALS { - let action = SigAction::new(SigHandler::SigIgn, SaFlags::empty(), SigSet::empty()); - // SAFETY: `sigaction` is the POSIX API for updating process signal - // dispositions. We install `SIG_IGN` for a small fixed set of - // terminal signals and store the previous handlers for restoration. - let old = unsafe { sigaction(signal, &action) }.into_diagnostic()?; - previous.push((signal, old)); - } - Ok(Self { previous }) - } -} - -#[cfg(unix)] -impl Drop for ParentSignalGuard { - #[allow(unsafe_code)] - fn drop(&mut self) { - for &(signal, previous) in self.previous.iter().rev() { - // SAFETY: these `SigAction` values were returned by `sigaction` - // above for this process, so restoring them here returns the parent - // signal handlers to their original state. - let _ = unsafe { sigaction(signal, &previous) }; - } - } -} - -#[cfg(unix)] -#[allow(unsafe_code)] -fn reset_transient_tty_signals(command: &mut Command) { - // SAFETY: `pre_exec` runs in the forked child immediately before `exec`. - // We only reset a small fixed set of signal handlers to `SIG_DFL`, which is - // required so SSH receives terminal signals normally even though the parent - // process temporarily ignores them to preserve cleanup. - unsafe { - command.pre_exec(|| { - for &signal in TRANSIENT_TTY_SIGNALS { - let action = SigAction::new(SigHandler::SigDfl, SaFlags::empty(), SigSet::empty()); - sigaction(signal, &action).map_err(|err| std::io::Error::other(err.to_string()))?; - } - Ok(()) - }); - } -} - -fn exec_or_wait(mut command: Command, replace_process: bool) -> Result { +async fn exec_or_wait(command: Command, replace_process: bool) -> Result { if replace_process && std::io::stdin().is_terminal() { #[cfg(unix)] { + let mut command = command; let err = command.exec(); return Err(miette::miette!("failed to exec ssh: {err}")); } } + // Catch terminal signals in the parent while SSH receives the same signals + // from the foreground process group. Unlike SIG_IGN, caught dispositions + // reset to their defaults when the child execs, so SSH stays interruptible. #[cfg(unix)] - let _signal_guard = if !replace_process && std::io::stdin().is_terminal() { - reset_transient_tty_signals(&mut command); - Some(ParentSignalGuard::ignore_transient_tty_signals()?) + let mut signals = if !replace_process && std::io::stdin().is_terminal() { + Some(TerminationSignals::new()?) } else { None }; - - let status = command.status().into_diagnostic()?; - - Ok(status.code().unwrap_or(1)) + let mut child = TokioCommand::from(command) + .kill_on_drop(true) + .spawn() + .into_diagnostic()?; + #[cfg(unix)] + if let Some(signals) = signals.as_mut() { + loop { + tokio::select! { + status = child.wait() => return Ok(process_exit_code(status.into_diagnostic()?)), + _ = signals.recv() => {} + } + } + } + Ok(process_exit_code(child.wait().await.into_diagnostic()?)) } fn main_attach_command(session: &SshSessionConfig) -> Command { @@ -324,9 +296,7 @@ fn main_attach_command(session: &SshSessionConfig) -> Command { async fn run_main_attach(session: &SshSessionConfig, replace_process: bool) -> Result { let command = main_attach_command(session); - tokio::task::spawn_blocking(move || exec_or_wait(command, replace_process)) - .await - .into_diagnostic()? + exec_or_wait(command, replace_process).await } fn process_exit_code(status: ExitStatus) -> i32 { @@ -367,6 +337,8 @@ struct ConnectCancellation { } impl ConnectCancellation { + // Unix signal registration is fallible; retain the shared API on Windows. + #[cfg_attr(not(unix), allow(clippy::unnecessary_wraps))] fn new() -> Result { Ok(Self { #[cfg(unix)] @@ -374,6 +346,8 @@ impl ConnectCancellation { }) } + // Waiting consumes mutable signal state only on Unix. + #[cfg_attr(not(unix), allow(clippy::needless_pass_by_ref_mut))] async fn wait(&mut self, future: F) -> std::result::Result where F: Future, @@ -419,6 +393,8 @@ async fn terminate_and_reap_child(child: &mut Child, signal: Signal) -> Result().is_ok() + { + break; + } + assert!( + probe.0.try_wait().unwrap().is_none(), + "probe exited before ready" + ); + assert!(Instant::now() < deadline, "probe did not become ready"); + std::thread::sleep(Duration::from_millis(10)); + } + killpg(Pid::from_raw(probe.0.id().cast_signed()), signal).unwrap(); + loop { + if let Some(status) = probe.0.try_wait().unwrap() { + assert!(status.success(), "parent failed under {signal}: {status}"); + break; + } + assert!( + Instant::now() < deadline, + "parent did not reap SSH after {signal}" + ); + std::thread::sleep(Duration::from_millis(10)); + } + drop(terminal.master); + } + } + use super::*; use crate::TEST_ENV_LOCK; + use crate::test_utils::Environment; #[test] fn upsert_host_block_appends_when_missing() { @@ -2545,55 +2643,41 @@ mod tests { } #[test] - #[allow(unsafe_code)] // Test-only: env vars require unsafe in Rust 2024. fn install_ssh_config_adds_include_once_and_updates_managed_file() { - let _guard = TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let home = tempfile::tempdir().unwrap(); let xdg = tempfile::tempdir().unwrap(); - let old_home = std::env::var("HOME").ok(); - let old_xdg = std::env::var("XDG_CONFIG_HOME").ok(); - unsafe { - std::env::set_var("HOME", home.path()); - std::env::set_var("XDG_CONFIG_HOME", xdg.path()); - } - - let ssh_dir = home.path().join(".ssh"); - fs::create_dir_all(&ssh_dir).unwrap(); - let user_config = ssh_dir.join("config"); - fs::write(&user_config, "Host personal\n HostName example.com\n").unwrap(); - - let managed_path = install_ssh_config("openshell", "demo", "default").unwrap(); - install_ssh_config("openshell", "demo", "default").unwrap(); - let main_contents = fs::read_to_string(&user_config).unwrap(); - assert!(main_contents.contains("Host personal")); - assert_eq!(main_contents.matches("Include ").count(), 1); - assert!(main_contents.contains(&render_include_line(&managed_path))); - let include_idx = main_contents.find("Include ").unwrap(); - let host_idx = main_contents.find("Host personal").unwrap(); - assert!(include_idx < host_idx); - - let managed_contents = fs::read_to_string(&managed_path).unwrap(); - assert_eq!( - managed_contents - .matches("Host openshell-demo.default") - .count(), - 1 - ); - assert!(managed_contents.contains("ProxyCommand")); - - unsafe { - match old_home { - Some(val) => std::env::set_var("HOME", val), - None => std::env::remove_var("HOME"), - } - match old_xdg { - Some(val) => std::env::set_var("XDG_CONFIG_HOME", val), - None => std::env::remove_var("XDG_CONFIG_HOME"), - } - } + Environment::new() + .set("HOME", home.path()) + .set("XDG_CONFIG_HOME", xdg.path()) + .run(|| { + let home = PathBuf::from(std::env::var_os("HOME").unwrap()); + + let ssh_dir = home.join(".ssh"); + fs::create_dir_all(&ssh_dir).unwrap(); + let user_config = ssh_dir.join("config"); + fs::write(&user_config, "Host personal\n HostName example.com\n").unwrap(); + + let managed_path = install_ssh_config("openshell", "demo", "default").unwrap(); + install_ssh_config("openshell", "demo", "default").unwrap(); + + let main_contents = fs::read_to_string(&user_config).unwrap(); + assert!(main_contents.contains("Host personal")); + assert_eq!(main_contents.matches("Include ").count(), 1); + assert!(main_contents.contains(&render_include_line(&managed_path))); + let include_idx = main_contents.find("Include ").unwrap(); + let host_idx = main_contents.find("Host personal").unwrap(); + assert!(include_idx < host_idx); + + let managed_contents = fs::read_to_string(&managed_path).unwrap(); + assert_eq!( + managed_contents + .matches("Host openshell-demo.default") + .count(), + 1 + ); + assert!(managed_contents.contains("ProxyCommand")); + }); } #[test] @@ -2741,97 +2825,76 @@ mod tests { } #[test] - #[allow(unsafe_code)] // Test-only: env vars require unsafe in Rust 2024. fn track_background_forward_or_cleanup_runs_cleanup_when_pidfile_write_fails() { - let _guard = TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = tempfile::tempdir().unwrap(); // Make forward PID-file writes fail with ENOTDIR after listener readiness. let blocking_file = tmp.path().join("not-a-dir"); fs::write(&blocking_file, b"x").unwrap(); - let old_xdg = std::env::var("XDG_CONFIG_HOME").ok(); - unsafe { - std::env::set_var("XDG_CONFIG_HOME", &blocking_file); - } - - let mut cleaned_up = false; - let result = track_background_forward_or_cleanup( - "default", - "demo", - 8080, - 4242, - "sbx-1", - "127.0.0.1", - || { - cleaned_up = true; - }, - ); - unsafe { - match old_xdg { - Some(val) => std::env::set_var("XDG_CONFIG_HOME", val), - None => std::env::remove_var("XDG_CONFIG_HOME"), - } - } + Environment::new() + .set("XDG_CONFIG_HOME", &blocking_file) + .run(|| { + let mut cleaned_up = false; + let result = track_background_forward_or_cleanup( + "default", + "demo", + 8080, + 4242, + "sbx-1", + "127.0.0.1", + || { + cleaned_up = true; + }, + ); - assert!( - result.is_err(), - "PID-file write failure must surface as an error" - ); - assert!( - cleaned_up, - "the owned SSH child must be cleaned up when tracking fails so no \ + assert!( + result.is_err(), + "PID-file write failure must surface as an error" + ); + assert!( + cleaned_up, + "the owned SSH child must be cleaned up when tracking fails so no \ reachable-but-untracked forward is left running" - ); + ); + }); } #[test] - #[allow(unsafe_code)] // Test-only: env vars require unsafe in Rust 2024. fn track_background_forward_or_cleanup_tracks_pid_without_cleanup_on_success() { - let _guard = TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = tempfile::tempdir().unwrap(); - let old_xdg = std::env::var("XDG_CONFIG_HOME").ok(); - unsafe { - std::env::set_var("XDG_CONFIG_HOME", tmp.path()); - } - - let mut cleaned_up = false; - let result = track_background_forward_or_cleanup( - "default", - "demo", - 8080, - 4242, - "sbx-1", - "127.0.0.1", - || { - cleaned_up = true; - }, - ); - let pid_file_exists = openshell_core::forward::forward_pid_path("default", "demo", 8080) - .is_ok_and(|path| path.exists()); - unsafe { - match old_xdg { - Some(val) => std::env::set_var("XDG_CONFIG_HOME", val), - None => std::env::remove_var("XDG_CONFIG_HOME"), - } - } + Environment::new() + .set("XDG_CONFIG_HOME", tmp.path()) + .run(|| { + let mut cleaned_up = false; + let result = track_background_forward_or_cleanup( + "default", + "demo", + 8080, + 4242, + "sbx-1", + "127.0.0.1", + || { + cleaned_up = true; + }, + ); + let pid_file_exists = + openshell_core::forward::forward_pid_path("default", "demo", 8080) + .is_ok_and(|path| path.exists()); - assert!( - result.is_ok(), - "a writable PID directory must track successfully" - ); - assert!( - pid_file_exists, - "successful tracking must persist a PID file" - ); - assert!( - !cleaned_up, - "successful tracking must not terminate the forward process" - ); + assert!( + result.is_ok(), + "a writable PID directory must track successfully" + ); + assert!( + pid_file_exists, + "successful tracking must persist a PID file" + ); + assert!( + !cleaned_up, + "successful tracking must not terminate the forward process" + ); + }); } #[test] diff --git a/crates/openshell-cli/src/test_utils.rs b/crates/openshell-cli/src/test_utils.rs index c26b19ff66..314df11090 100644 --- a/crates/openshell-cli/src/test_utils.rs +++ b/crates/openshell-cli/src/test_utils.rs @@ -1,57 +1,19 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use std::path::Path; +use std::path::{Path, PathBuf}; -use crate::TEST_ENV_LOCK; +#[path = "../../../tests/support/environment.rs"] +mod environment; +pub use environment::Environment; -pub struct EnvVarGuard { - key: &'static str, - original: Option, +pub fn with_tmp_xdg(tmp: &Path, f: impl FnOnce(&Path)) { + with_tmp_xdg_env(tmp, Environment::new(), f); } -#[allow(unsafe_code)] -impl EnvVarGuard { - pub fn set(key: &'static str, value: &str) -> Self { - let original = std::env::var(key).ok(); - unsafe { - std::env::set_var(key, value); - } - Self { key, original } - } - - pub fn unset(key: &'static str) -> Self { - let original = std::env::var(key).ok(); - unsafe { - std::env::remove_var(key); - } - Self { key, original } - } -} - -#[allow(unsafe_code)] -impl Drop for EnvVarGuard { - fn drop(&mut self) { - if let Some(value) = &self.original { - unsafe { - std::env::set_var(self.key, value); - } - } else { - unsafe { - std::env::remove_var(self.key); - } - } - } -} - -pub fn with_tmp_xdg(tmp: &Path, f: F) { - let _guard = TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let guard = EnvVarGuard::set( - "XDG_CONFIG_HOME", - tmp.to_str().expect("temp path should be utf-8"), - ); - f(); - drop(guard); +pub fn with_tmp_xdg_env(tmp: &Path, env: Environment, f: impl FnOnce(&Path)) { + env.set("XDG_CONFIG_HOME", tmp).run(|| { + let root = PathBuf::from(std::env::var_os("XDG_CONFIG_HOME").unwrap()); + f(&root); + }); } diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index c08f49296b..2a188066a4 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -7,7 +7,8 @@ mod helpers; -use helpers::{EnvVarGuard, build_ca, build_client_cert, build_server_cert}; +use helpers::TempDir; +use helpers::{Environment, build_ca, build_client_cert, build_server_cert}; use openshell_cli::run; use openshell_cli::tls::TlsOptions; use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; @@ -30,7 +31,6 @@ use openshell_core::proto::{ use openshell_core::{ObjectId, ObjectName}; use std::collections::HashMap; use std::sync::Arc; -use tempfile::TempDir; use tokio::net::TcpListener; use tokio::sync::{Mutex, mpsc}; use tokio_stream::wrappers::TcpListenerStream; @@ -846,7 +846,7 @@ async fn run_server() -> TestServer { .unwrap(); }); - let dir = tempfile::tempdir().unwrap(); + let dir = helpers::tempdir().unwrap(); let ca_path = dir.path().join("ca.crt"); let cert_path = dir.path().join("tls.crt"); let key_path = dir.path().join("tls.key"); @@ -901,33 +901,36 @@ async fn explicit_provider_name_passes_through_when_it_exists() { #[tokio::test] async fn explicit_provider_name_auto_creates_when_valid_type() { let ts = run_server().await; - let _guard = EnvVarGuard::set(&[("NVIDIA_API_KEY", "nvapi-test-key")]); - let mut client = openshell_cli::tls::grpc_client(&ts.endpoint, &ts.tls) - .await - .expect("grpc client"); - - let result = run::ensure_required_providers( - &mut client, - &["nvidia".to_string()], - Some(true), // --auto-providers to skip interactive prompt - "default", - ) - .await - .expect("should auto-create the provider"); - - assert_eq!(result, vec!["nvidia".to_string()]); - - // Verify the provider was created on the server with the right type. - let providers = ts.openshell.state.providers.lock().await; - let provider = providers - .get("nvidia") - .expect("nvidia provider should exist"); - assert_eq!(provider.r#type, "nvidia"); - assert_eq!( - provider.credentials.get("NVIDIA_API_KEY"), - Some(&"nvapi-test-key".to_string()), - ); + Environment::from_pairs(&[("NVIDIA_API_KEY", "nvapi-test-key")]) + .run_async(async { + let mut client = openshell_cli::tls::grpc_client(&ts.endpoint, &ts.tls) + .await + .expect("grpc client"); + + let result = run::ensure_required_providers( + &mut client, + &["nvidia".to_string()], + Some(true), // --auto-providers to skip interactive prompt + "default", + ) + .await + .expect("should auto-create the provider"); + + assert_eq!(result, vec!["nvidia".to_string()]); + + // Verify the provider was created on the server with the right type. + let providers = ts.openshell.state.providers.lock().await; + let provider = providers + .get("nvidia") + .expect("nvidia provider should exist"); + assert_eq!(provider.r#type, "nvidia"); + assert_eq!( + provider.credentials.get("NVIDIA_API_KEY"), + Some(&"nvapi-test-key".to_string()), + ); + }) + .await; } /// When `--provider my-custom-thing` is passed and "my-custom-thing" is not a @@ -965,94 +968,103 @@ async fn explicit_provider_name_errors_for_unrecognised_name() { #[tokio::test] async fn no_auto_providers_skips_missing_explicit_provider() { let ts = run_server().await; - let _guard = EnvVarGuard::set(&[("NVIDIA_API_KEY", "nvapi-skip-test")]); - let mut client = openshell_cli::tls::grpc_client(&ts.endpoint, &ts.tls) - .await - .expect("grpc client"); - - let result = run::ensure_required_providers( - &mut client, - &["nvidia".to_string()], - Some(false), // --no-auto-providers - "default", - ) - .await - .expect("should succeed with empty list"); - - assert!( - result.is_empty(), - "skipped providers should not appear in the result" - ); - - let providers = ts.openshell.state.providers.lock().await; - assert!( - providers.is_empty(), - "no providers should be created when --no-auto-providers is set" - ); + Environment::from_pairs(&[("NVIDIA_API_KEY", "nvapi-skip-test")]) + .run_async(async { + let mut client = openshell_cli::tls::grpc_client(&ts.endpoint, &ts.tls) + .await + .expect("grpc client"); + + let result = run::ensure_required_providers( + &mut client, + &["nvidia".to_string()], + Some(false), // --no-auto-providers + "default", + ) + .await + .expect("should succeed with empty list"); + + assert!( + result.is_empty(), + "skipped providers should not appear in the result" + ); + + let providers = ts.openshell.state.providers.lock().await; + assert!( + providers.is_empty(), + "no providers should be created when --no-auto-providers is set" + ); + }) + .await; } /// Several explicit providers are all resolved and created. #[tokio::test] async fn multiple_explicit_providers_combined() { let ts = run_server().await; - let _guard = EnvVarGuard::set(&[ + + Environment::from_pairs(&[ ("NVIDIA_API_KEY", "nvapi-combo"), ("ANTHROPIC_API_KEY", "sk-ant-combo"), - ]); - - let mut client = openshell_cli::tls::grpc_client(&ts.endpoint, &ts.tls) + ]) + .run_async(async { + let mut client = openshell_cli::tls::grpc_client(&ts.endpoint, &ts.tls) + .await + .expect("grpc client"); + + let result = run::ensure_required_providers( + &mut client, + &["nvidia".to_string(), "claude-code".to_string()], + Some(true), + "default", + ) .await - .expect("grpc client"); - - let result = run::ensure_required_providers( - &mut client, - &["nvidia".to_string(), "claude-code".to_string()], - Some(true), - "default", - ) - .await - .expect("should create both providers"); - - assert_eq!(result.len(), 2); - assert!(result.contains(&"nvidia".to_string())); - assert!(result.contains(&"claude-code".to_string())); - - let providers = ts.openshell.state.providers.lock().await; - assert_eq!(providers.len(), 2); - assert!(providers.contains_key("nvidia")); - assert!(providers.contains_key("claude-code")); + .expect("should create both providers"); + + assert_eq!(result.len(), 2); + assert!(result.contains(&"nvidia".to_string())); + assert!(result.contains(&"claude-code".to_string())); + + let providers = ts.openshell.state.providers.lock().await; + assert_eq!(providers.len(), 2); + assert!(providers.contains_key("nvidia")); + assert!(providers.contains_key("claude-code")); + }) + .await; } /// A provider named twice appears only once in the result. #[tokio::test] async fn repeated_explicit_provider_deduplicates() { let ts = run_server().await; - let _guard = EnvVarGuard::set(&[("NVIDIA_API_KEY", "nvapi-dedup")]); - let mut client = openshell_cli::tls::grpc_client(&ts.endpoint, &ts.tls) - .await - .expect("grpc client"); - - let result = run::ensure_required_providers( - &mut client, - &["nvidia".to_string(), "nvidia".to_string()], - Some(true), - "default", - ) - .await - .expect("should succeed"); - - assert_eq!( - result, - vec!["nvidia".to_string()], - "nvidia should appear exactly once" - ); - - let providers = ts.openshell.state.providers.lock().await; - assert_eq!( - providers.len(), - 1, - "only one provider should be created on the server" - ); + Environment::from_pairs(&[("NVIDIA_API_KEY", "nvapi-dedup")]) + .run_async(async { + let mut client = openshell_cli::tls::grpc_client(&ts.endpoint, &ts.tls) + .await + .expect("grpc client"); + + let result = run::ensure_required_providers( + &mut client, + &["nvidia".to_string(), "nvidia".to_string()], + Some(true), + "default", + ) + .await + .expect("should succeed"); + + assert_eq!( + result, + vec!["nvidia".to_string()], + "nvidia should appear exactly once" + ); + + let providers = ts.openshell.state.providers.lock().await; + assert_eq!( + providers.len(), + 1, + "only one provider should be created on the server" + ); + }) + .await; } diff --git a/crates/openshell-cli/tests/helpers/mod.rs b/crates/openshell-cli/tests/helpers/mod.rs index 9c446a1b56..9659f4c69b 100644 --- a/crates/openshell-cli/tests/helpers/mod.rs +++ b/crates/openshell-cli/tests/helpers/mod.rs @@ -115,62 +115,10 @@ use rcgen::{ BasicConstraints, Certificate, CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair, }; -// ── EnvVarGuard ────────────────────────────────────────────────────────────── - -/// Global mutex that serialises tests which mutate environment variables so -/// concurrent threads don't clobber each other's state. -static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -struct SavedVar { - key: &'static str, - original: Option, -} - -/// RAII guard that acquires `ENV_LOCK` and restores all modified environment -/// variables on drop. -pub struct EnvVarGuard { - vars: Vec, - _lock: std::sync::MutexGuard<'static, ()>, -} - -#[allow(dead_code, unsafe_code)] -impl EnvVarGuard { - /// Acquire the global env-var lock and atomically set one or more - /// environment variables. All variables are restored to their prior - /// state (or removed) when the guard is dropped. - pub fn set(pairs: &[(&'static str, &str)]) -> Self { - let lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let mut vars = Vec::with_capacity(pairs.len()); - for &(key, value) in pairs { - let original = std::env::var(key).ok(); - unsafe { - std::env::set_var(key, value); - } - vars.push(SavedVar { key, original }); - } - Self { vars, _lock: lock } - } -} - -#[allow(unsafe_code)] -impl Drop for EnvVarGuard { - fn drop(&mut self) { - for var in &self.vars { - if let Some(value) = &var.original { - unsafe { - std::env::set_var(var.key, value); - } - } else { - unsafe { - std::env::remove_var(var.key); - } - } - } - // _lock drops here, releasing the mutex - } -} +#[path = "../../../../tests/support/environment.rs"] +mod environment; +#[allow(unused_imports)] +pub use environment::{Environment, FixtureDir as TempDir, fixture_dir as tempdir}; // ── TLS helpers ────────────────────────────────────────────────────────────── diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 6a4bafd19d..4428666184 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -3,7 +3,8 @@ mod helpers; -use helpers::{EnvVarGuard, build_ca, build_client_cert, build_server_cert}; +use helpers::tempdir; +use helpers::{Environment, build_ca, build_client_cert, build_server_cert}; use openshell_bootstrap::{get_gateway_metadata, load_active_gateway}; use openshell_cli::{ run, @@ -18,7 +19,6 @@ use openshell_core::proto::{ UpdateProviderRequest, open_shell_server::{OpenShell, OpenShellServer}, }; -use tempfile::tempdir; use tokio::net::TcpListener; use tokio::sync::mpsc; use tokio_stream::wrappers::TcpListenerStream; @@ -708,13 +708,13 @@ fn write_gateway_mtls_bundle( fn isolated_gateway_add_env( config_dir: &std::path::Path, state_dir: &std::path::Path, -) -> EnvVarGuard { +) -> Environment { let xdg_config = config_dir.to_string_lossy().into_owned(); let xdg_state = state_dir.to_string_lossy().into_owned(); let local_tls_dir = state_dir.join("no-package-managed-tls"); let local_tls = local_tls_dir.to_string_lossy().into_owned(); - EnvVarGuard::set(&[ + Environment::from_pairs(&[ ("XDG_CONFIG_HOME", xdg_config.as_str()), ("XDG_STATE_HOME", xdg_state.as_str()), ("HOME", xdg_state.as_str()), @@ -740,29 +740,32 @@ async fn gateway_add_mtls_loopback_uses_explicit_gateway_name() { &client_cert, &client_key, ); - let _env = isolated_gateway_add_env(config_dir.path(), state_dir.path()); - - let endpoint = format!("https://localhost:{}", addr.port()); - run::gateway_add( - &endpoint, - Some("k8s"), - None, - true, - None, - "openshell-cli", - None, - None, - false, - ) - .await - .unwrap(); - - let metadata = get_gateway_metadata("k8s").unwrap(); - assert_eq!(metadata.name, "k8s"); - assert_eq!(metadata.gateway_endpoint, endpoint); - assert_eq!(metadata.auth_mode.as_deref(), Some("mtls")); - assert_eq!(load_active_gateway().as_deref(), Some("k8s")); - assert!(get_gateway_metadata("openshell").is_none()); + + isolated_gateway_add_env(config_dir.path(), state_dir.path()) + .run_async(async { + let endpoint = format!("https://localhost:{}", addr.port()); + run::gateway_add( + &endpoint, + Some("k8s"), + None, + true, + None, + "openshell-cli", + None, + None, + false, + ) + .await + .unwrap(); + + let metadata = get_gateway_metadata("k8s").unwrap(); + assert_eq!(metadata.name, "k8s"); + assert_eq!(metadata.gateway_endpoint, endpoint); + assert_eq!(metadata.auth_mode.as_deref(), Some("mtls")); + assert_eq!(load_active_gateway().as_deref(), Some("k8s")); + assert!(get_gateway_metadata("openshell").is_none()); + }) + .await; } #[tokio::test] @@ -782,28 +785,31 @@ async fn gateway_add_mtls_loopback_without_name_uses_openshell_default() { &client_cert, &client_key, ); - let _env = isolated_gateway_add_env(config_dir.path(), state_dir.path()); - - let endpoint = format!("https://localhost:{}", addr.port()); - run::gateway_add( - &endpoint, - None, - None, - true, - None, - "openshell-cli", - None, - None, - false, - ) - .await - .unwrap(); - - let metadata = get_gateway_metadata("openshell").unwrap(); - assert_eq!(metadata.name, "openshell"); - assert_eq!(metadata.gateway_endpoint, endpoint); - assert_eq!(metadata.auth_mode.as_deref(), Some("mtls")); - assert_eq!(load_active_gateway().as_deref(), Some("openshell")); + + isolated_gateway_add_env(config_dir.path(), state_dir.path()) + .run_async(async { + let endpoint = format!("https://localhost:{}", addr.port()); + run::gateway_add( + &endpoint, + None, + None, + true, + None, + "openshell-cli", + None, + None, + false, + ) + .await + .unwrap(); + + let metadata = get_gateway_metadata("openshell").unwrap(); + assert_eq!(metadata.name, "openshell"); + assert_eq!(metadata.gateway_endpoint, endpoint); + assert_eq!(metadata.auth_mode.as_deref(), Some("mtls")); + assert_eq!(load_active_gateway().as_deref(), Some("openshell")); + }) + .await; } #[tokio::test] @@ -821,51 +827,56 @@ async fn gateway_add_mtls_loopback_explicit_name_does_not_fallback_to_openshell_ &client_cert, &client_key, ); - let _env = isolated_gateway_add_env(config_dir.path(), state_dir.path()); - - let err = run::gateway_add( - "https://localhost:1", - Some("k8s"), - None, - true, - None, - "openshell-cli", - None, - None, - false, - ) - .await - .expect_err("explicit name should require matching named mTLS material"); - - assert!(err.to_string().contains("gateway 'k8s'")); - assert!(get_gateway_metadata("k8s").is_none()); - assert!(load_active_gateway().is_none()); + + isolated_gateway_add_env(config_dir.path(), state_dir.path()) + .run_async(async { + let err = run::gateway_add( + "https://localhost:1", + Some("k8s"), + None, + true, + None, + "openshell-cli", + None, + None, + false, + ) + .await + .expect_err("explicit name should require matching named mTLS material"); + + assert!(err.to_string().contains("gateway 'k8s'")); + assert!(get_gateway_metadata("k8s").is_none()); + assert!(load_active_gateway().is_none()); + }) + .await; } #[tokio::test] async fn cli_connects_with_client_cert() { - let _env = EnvVarGuard::set(&[]); - - let (ca, ca_key) = build_ca(); - let (server_cert, server_key) = build_server_cert(&ca, &ca_key); - let (client_cert, client_key) = build_client_cert(&ca, &ca_key); - let ca_cert = ca.pem(); - - let addr = run_server(server_cert, server_key, ca_cert.clone()).await; - - let dir = tempdir().unwrap(); - let ca_path = dir.path().join("ca.crt"); - let cert_path = dir.path().join("tls.crt"); - let key_path = dir.path().join("tls.key"); - std::fs::write(&ca_path, ca_cert).unwrap(); - std::fs::write(&cert_path, client_cert).unwrap(); - std::fs::write(&key_path, client_key).unwrap(); - - let tls = TlsOptions::new(Some(ca_path), Some(cert_path), Some(key_path)); - let endpoint = format!("https://localhost:{}", addr.port()); - let mut client = grpc_client(&endpoint, &tls).await.unwrap(); - let response = client.health(HealthRequest {}).await.unwrap(); - assert_eq!(response.get_ref().status, ServiceStatus::Healthy as i32); + Environment::from_pairs(&[]) + .run_async(async { + let (ca, ca_key) = build_ca(); + let (server_cert, server_key) = build_server_cert(&ca, &ca_key); + let (client_cert, client_key) = build_client_cert(&ca, &ca_key); + let ca_cert = ca.pem(); + + let addr = run_server(server_cert, server_key, ca_cert.clone()).await; + + let dir = tempdir().unwrap(); + let ca_path = dir.path().join("ca.crt"); + let cert_path = dir.path().join("tls.crt"); + let key_path = dir.path().join("tls.key"); + std::fs::write(&ca_path, ca_cert).unwrap(); + std::fs::write(&cert_path, client_cert).unwrap(); + std::fs::write(&key_path, client_key).unwrap(); + + let tls = TlsOptions::new(Some(ca_path), Some(cert_path), Some(key_path)); + let endpoint = format!("https://localhost:{}", addr.port()); + let mut client = grpc_client(&endpoint, &tls).await.unwrap(); + let response = client.health(HealthRequest {}).await.unwrap(); + assert_eq!(response.get_ref().status, ServiceStatus::Healthy as i32); + }) + .await; } #[tokio::test] @@ -880,14 +891,18 @@ async fn cli_requires_client_cert_for_https() { // Point XDG_CONFIG_HOME at the isolated temp dir so that default_tls_dir // cannot discover real client certs from the developer's machine. let xdg_path = dir.path().to_string_lossy(); - let _xdg_env = EnvVarGuard::set(&[("XDG_CONFIG_HOME", &xdg_path)]); - let ca_path = dir.path().join("ca.crt"); - std::fs::write(&ca_path, ca_cert).unwrap(); - - let tls = TlsOptions::new(Some(ca_path), None, None); - let endpoint = format!("https://localhost:{}", addr.port()); - let result = grpc_client(&endpoint, &tls).await; - assert!(result.is_err()); + + Environment::from_pairs(&[("XDG_CONFIG_HOME", &xdg_path)]) + .run_async(async { + let ca_path = dir.path().join("ca.crt"); + std::fs::write(&ca_path, ca_cert).unwrap(); + + let tls = TlsOptions::new(Some(ca_path), None, None); + let endpoint = format!("https://localhost:{}", addr.port()); + let result = grpc_client(&endpoint, &tls).await; + assert!(result.is_err()); + }) + .await; } async fn run_server_no_client_auth( @@ -915,18 +930,20 @@ async fn run_server_no_client_auth( #[tokio::test] async fn cli_connects_with_gateway_insecure() { - let _env = EnvVarGuard::set(&[]); - - let (ca, ca_key) = build_ca(); - let (server_cert, server_key) = build_server_cert(&ca, &ca_key); - - let addr = run_server_no_client_auth(server_cert, server_key).await; - - let mut tls = TlsOptions::default(); - tls.gateway_insecure = true; - - let endpoint = format!("https://localhost:{}", addr.port()); - let mut client = grpc_client(&endpoint, &tls).await.unwrap(); - let response = client.health(HealthRequest {}).await.unwrap(); - assert_eq!(response.get_ref().status, ServiceStatus::Healthy as i32); + Environment::from_pairs(&[]) + .run_async(async { + let (ca, ca_key) = build_ca(); + let (server_cert, server_key) = build_server_cert(&ca, &ca_key); + + let addr = run_server_no_client_auth(server_cert, server_key).await; + + let mut tls = TlsOptions::default(); + tls.gateway_insecure = true; + + let endpoint = format!("https://localhost:{}", addr.port()); + let mut client = grpc_client(&endpoint, &tls).await.unwrap(); + let response = client.health(HealthRequest {}).await.unwrap(); + assert_eq!(response.get_ref().status, ServiceStatus::Healthy as i32); + }) + .await; } diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index e53352d079..786ff0a156 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -3,7 +3,8 @@ mod helpers; -use helpers::{EnvVarGuard, build_ca, build_client_cert, build_server_cert}; +use helpers::TempDir; +use helpers::{Environment, build_ca, build_client_cert, build_server_cert}; use openshell_cli::run; use openshell_cli::tls::TlsOptions; use openshell_core::proto::open_shell_server::{OpenShell, OpenShellServer}; @@ -37,7 +38,6 @@ use std::collections::{HashMap, VecDeque}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::time::Duration; -use tempfile::TempDir; use tokio::net::TcpListener; use tokio::sync::{Mutex, mpsc}; use tokio_stream::wrappers::TcpListenerStream; @@ -1653,7 +1653,7 @@ async fn run_server() -> TestServer { .unwrap(); }); - let dir = tempfile::tempdir().unwrap(); + let dir = helpers::tempdir().unwrap(); let ca_path = dir.path().join("ca.crt"); let cert_path = dir.path().join("tls.crt"); let key_path = dir.path().join("tls.key"); @@ -1803,7 +1803,7 @@ fn provider_wait_options() -> run::ProviderWaitOptions<'static> { // A separate CLI process provides isolated stdout/stderr without redirecting // the test runner's descriptors or sharing the user's gateway configuration. async fn run_readiness_cli(server: &TestServer, args: &[&str]) -> std::process::Output { - let config_dir = tempfile::tempdir().unwrap(); + let config_dir = helpers::tempdir().unwrap(); let tls_dir = config_dir .path() .join("openshell/gateways/provider-readiness/mtls"); @@ -3586,7 +3586,7 @@ async fn profile_commands_dispatch_scope_and_output_to_gateway() { .await .expect("test gateway"); }); - let config = tempfile::tempdir().expect("isolated CLI config"); + let config = helpers::tempdir().expect("isolated CLI config"); // The subprocess uses an explicit local endpoint and isolated config so // profile dispatch cannot accidentally consult the developer's gateway. for (verb, global, format) in [ @@ -3930,73 +3930,80 @@ async fn provider_refresh_configure_reads_secret_material_from_env_off_argv() { .expect("provider create"); // The env value reaches the request and is auto-marked secret. - let guard = EnvVarGuard::set(&[("OPENSHELL_ITEST_SME_PRIVATE_KEY", "pem-from-env")]); - run::provider_refresh_config( - &ts.endpoint, - run::ProviderRefreshConfigInput { - name: "gc-bridge", - credential_key: "GOOGLE_CHAT_ACCESS_TOKEN", - strategy: "google_service_account_jwt", - material: &["client_email=bot@p.iam.gserviceaccount.com".to_string()], - secret_material_env: &["private_key=OPENSHELL_ITEST_SME_PRIVATE_KEY".to_string()], - secret_material_keys: &[], - credential_expires_at_ms: None, - }, - "default", - &ts.tls, - ) - .await - .expect("provider refresh configure"); - drop(guard); - let requests = ts.state.refresh_requests.lock().await.clone(); - assert_eq!( - requests, - vec![ProviderRefreshRequestLog::Configure { - provider: "gc-bridge".to_string(), - credential_key: "GOOGLE_CHAT_ACCESS_TOKEN".to_string(), - material: HashMap::from([ - ( - "client_email".to_string(), - "bot@p.iam.gserviceaccount.com".to_string() - ), - ("private_key".to_string(), "pem-from-env".to_string()), - ]), - secret_material_keys: vec!["private_key".to_string()], - expires_at_ms: None, - }] - ); + Environment::from_pairs(&[("OPENSHELL_ITEST_SME_PRIVATE_KEY", "pem-from-env")]) + .run_async(async { + run::provider_refresh_config( + &ts.endpoint, + run::ProviderRefreshConfigInput { + name: "gc-bridge", + credential_key: "GOOGLE_CHAT_ACCESS_TOKEN", + strategy: "google_service_account_jwt", + material: &["client_email=bot@p.iam.gserviceaccount.com".to_string()], + secret_material_env: &[ + "private_key=OPENSHELL_ITEST_SME_PRIVATE_KEY".to_string() + ], + secret_material_keys: &[], + credential_expires_at_ms: None, + }, + "default", + &ts.tls, + ) + .await + .expect("provider refresh configure"); + + let requests = ts.state.refresh_requests.lock().await.clone(); + assert_eq!( + requests, + vec![ProviderRefreshRequestLog::Configure { + provider: "gc-bridge".to_string(), + credential_key: "GOOGLE_CHAT_ACCESS_TOKEN".to_string(), + material: HashMap::from([ + ( + "client_email".to_string(), + "bot@p.iam.gserviceaccount.com".to_string() + ), + ("private_key".to_string(), "pem-from-env".to_string()), + ]), + secret_material_keys: vec!["private_key".to_string()], + expires_at_ms: None, + }] + ); + }) + .await; } #[tokio::test] async fn provider_refresh_configure_rejects_key_supplied_via_both_material_and_env() { let ts = run_server().await; - let guard = EnvVarGuard::set(&[("OPENSHELL_ITEST_SME_DUP_KEY", "pem-from-env")]); - let err = run::provider_refresh_config( - &ts.endpoint, - run::ProviderRefreshConfigInput { - name: "gc-bridge", - credential_key: "GOOGLE_CHAT_ACCESS_TOKEN", - strategy: "google_service_account_jwt", - material: &["private_key=argv-value".to_string()], - secret_material_env: &["private_key=OPENSHELL_ITEST_SME_DUP_KEY".to_string()], - secret_material_keys: &[], - credential_expires_at_ms: None, - }, - "default", - &ts.tls, - ) - .await - .expect_err("duplicate key across --material and --secret-material-env should fail"); - drop(guard); + Environment::from_pairs(&[("OPENSHELL_ITEST_SME_DUP_KEY", "pem-from-env")]) + .run_async(async { + let err = run::provider_refresh_config( + &ts.endpoint, + run::ProviderRefreshConfigInput { + name: "gc-bridge", + credential_key: "GOOGLE_CHAT_ACCESS_TOKEN", + strategy: "google_service_account_jwt", + material: &["private_key=argv-value".to_string()], + secret_material_env: &["private_key=OPENSHELL_ITEST_SME_DUP_KEY".to_string()], + secret_material_keys: &[], + credential_expires_at_ms: None, + }, + "default", + &ts.tls, + ) + .await + .expect_err("duplicate key across --material and --secret-material-env should fail"); - assert!( - err.to_string() - .contains("duplicate material key 'private_key'") - ); - // Rejected client-side: nothing reached the gateway. - assert!(ts.state.refresh_requests.lock().await.is_empty()); + assert!( + err.to_string() + .contains("duplicate material key 'private_key'") + ); + // Rejected client-side: nothing reached the gateway. + assert!(ts.state.refresh_requests.lock().await.is_empty()); + }) + .await; } #[tokio::test] @@ -4351,7 +4358,7 @@ async fn sandbox_provider_attach_cli_surfaces_server_errors() { #[tokio::test] async fn provider_profile_cli_run_functions_support_custom_profiles() { let ts = run_server().await; - let dir = tempfile::tempdir().unwrap(); + let dir = helpers::tempdir().unwrap(); let profile_path = dir.path().join("custom-api.yaml"); std::fs::write( &profile_path, @@ -4511,75 +4518,82 @@ async fn provider_create_from_existing_uses_profile_discovery() { ..Default::default() }, ); - let _env = EnvVarGuard::set(&[("CUSTOM_DISCOVERY_API_KEY", "profile-secret")]); - run::provider_create( - &ts.endpoint, - "custom-discovered", - "custom-discovery", - true, - &[], - false, - &[], - "default", - &ts.tls, - ) - .await - .expect("profile-backed provider create --from-existing"); + Environment::from_pairs(&[("CUSTOM_DISCOVERY_API_KEY", "profile-secret")]) + .run_async(async { + run::provider_create( + &ts.endpoint, + "custom-discovered", + "custom-discovery", + true, + &[], + false, + &[], + "default", + &ts.tls, + ) + .await + .expect("profile-backed provider create --from-existing"); - let provider = ts - .state - .providers - .lock() - .await - .get("custom-discovered") - .cloned() - .expect("custom provider should be stored"); - assert_eq!(provider.r#type, "custom-discovery"); - assert_eq!( - provider.credentials.get("CUSTOM_DISCOVERY_API_KEY"), - Some(&"profile-secret".to_string()) - ); + let provider = ts + .state + .providers + .lock() + .await + .get("custom-discovered") + .cloned() + .expect("custom provider should be stored"); + assert_eq!(provider.r#type, "custom-discovery"); + assert_eq!( + provider.credentials.get("CUSTOM_DISCOVERY_API_KEY"), + Some(&"profile-secret".to_string()) + ); + }) + .await; } #[tokio::test] async fn provider_create_from_existing_uses_builtin_profile_discovery() { let ts = run_server().await; - let _env = EnvVarGuard::set(&[("OPENAI_API_KEY", "legacy-openai-secret")]); - run::provider_create( - &ts.endpoint, - "legacy-openai", - "openai", - true, - &[], - false, - &[], - "default", - &ts.tls, - ) - .await - .expect("legacy provider create --from-existing"); + Environment::from_pairs(&[("OPENAI_API_KEY", "legacy-openai-secret")]) + .run_async(async { + run::provider_create( + &ts.endpoint, + "legacy-openai", + "openai", + true, + &[], + false, + &[], + "default", + &ts.tls, + ) + .await + .expect("legacy provider create --from-existing"); - let provider = ts - .state - .providers - .lock() - .await - .get("legacy-openai") - .cloned() - .expect("legacy provider should be stored"); - assert_eq!(provider.r#type, "openai"); - assert_eq!( - provider.credentials.get("OPENAI_API_KEY"), - Some(&"legacy-openai-secret".to_string()) - ); + let provider = ts + .state + .providers + .lock() + .await + .get("legacy-openai") + .cloned() + .expect("legacy provider should be stored"); + assert_eq!(provider.r#type, "openai"); + assert_eq!( + provider.credentials.get("OPENAI_API_KEY"), + Some(&"legacy-openai-secret".to_string()) + ); + }) + .await; } #[tokio::test] async fn provider_create_from_existing_vertex_discovers_credentials_and_config() { let ts = run_server().await; - let _env = EnvVarGuard::set(&[ + + Environment::from_pairs(&[ ("VERTEX_AI_TOKEN", "ya29.vertex-v2-fallback"), ("VERTEX_AI_PROJECT_ID", "vertex-v2-project"), ("VERTEX_AI_REGION", "europe-west4"), @@ -4588,7 +4602,7 @@ async fn provider_create_from_existing_vertex_discovers_credentials_and_config() "https://aiplatform.googleapis.com/v1beta1/projects/vertex-v2-project/locations/global/endpoints/openapi", ), ("VERTEX_AI_PUBLISHER", "anthropic"), - ]); + ]).run_async(async { run::provider_create( &ts.endpoint, @@ -4636,6 +4650,7 @@ async fn provider_create_from_existing_vertex_discovers_credentials_and_config() provider.config.get("VERTEX_AI_PUBLISHER"), Some(&"anthropic".to_string()) ); + }).await; } #[tokio::test] @@ -4643,28 +4658,31 @@ async fn provider_create_from_existing_requires_profile() { let ts = run_server().await; // Use "generic" which is a normalised type but has no built-in provider // profile, so v2 profile-based discovery fails with the expected message. - let _env = EnvVarGuard::set(&[("GENERIC_API_KEY", "some-secret")]); - let err = run::provider_create( - &ts.endpoint, - "v2-generic", - "generic", - true, - &[], - false, - &[], - "default", - &ts.tls, - ) - .await - .expect_err("v2 discovery without a profile should fail"); + Environment::from_pairs(&[("GENERIC_API_KEY", "some-secret")]) + .run_async(async { + let err = run::provider_create( + &ts.endpoint, + "v2-generic", + "generic", + true, + &[], + false, + &[], + "default", + &ts.tls, + ) + .await + .expect_err("v2 discovery without a profile should fail"); - assert!( - err.to_string() - .contains("import a matching profile before using this provider type"), - "unexpected error: {err}" - ); - assert!(!ts.state.providers.lock().await.contains_key("v2-generic")); + assert!( + err.to_string() + .contains("import a matching profile before using this provider type"), + "unexpected error: {err}" + ); + assert!(!ts.state.providers.lock().await.contains_key("v2-generic")); + }) + .await; } #[tokio::test] @@ -4752,35 +4770,38 @@ async fn provider_update_from_existing_uses_profile_discovery() { credential_handles: HashMap::new(), }, ); - let _env = EnvVarGuard::set(&[("CUSTOM_UPDATE_DISCOVERY_API_KEY", "updated-profile-secret")]); - run::provider_update(run::ProviderUpdateOptions { - server: &ts.endpoint, - name: "custom-update", - from_existing: true, - from_oidc_token: false, - credentials: &[], - config: &[], - credential_expires_at: &[], - workspace: "default", - tls: &ts.tls, - readiness: run::ProviderWaitOptions::default(), - }) - .await - .expect("profile-backed provider update --from-existing"); + Environment::from_pairs(&[("CUSTOM_UPDATE_DISCOVERY_API_KEY", "updated-profile-secret")]) + .run_async(async { + run::provider_update(run::ProviderUpdateOptions { + server: &ts.endpoint, + name: "custom-update", + from_existing: true, + from_oidc_token: false, + credentials: &[], + config: &[], + credential_expires_at: &[], + workspace: "default", + tls: &ts.tls, + readiness: run::ProviderWaitOptions::default(), + }) + .await + .expect("profile-backed provider update --from-existing"); - let provider = ts - .state - .providers - .lock() - .await - .get("custom-update") - .cloned() - .expect("custom provider should still be stored"); - assert_eq!( - provider.credentials.get("CUSTOM_UPDATE_DISCOVERY_API_KEY"), - Some(&"updated-profile-secret".to_string()) - ); + let provider = ts + .state + .providers + .lock() + .await + .get("custom-update") + .cloned() + .expect("custom provider should still be stored"); + assert_eq!( + provider.credentials.get("CUSTOM_UPDATE_DISCOVERY_API_KEY"), + Some(&"updated-profile-secret".to_string()) + ); + }) + .await; } #[tokio::test] @@ -4822,43 +4843,46 @@ async fn provider_update_from_existing_preserves_global_profile_scope() { ..Default::default() }, ); - let _env = EnvVarGuard::set(&[ + + Environment::from_pairs(&[ ("GLOBAL_UPDATE_DISCOVERY_API_KEY", "global-secret"), ("WORKSPACE_UPDATE_DISCOVERY_API_KEY", "workspace-secret"), - ]); + ]) + .run_async(async { + run::provider_update(run::ProviderUpdateOptions { + server: &ts.endpoint, + name: "global-update", + from_existing: true, + from_oidc_token: false, + credentials: &[], + config: &[], + credential_expires_at: &[], + workspace: "default", + tls: &ts.tls, + readiness: run::ProviderWaitOptions::default(), + }) + .await + .expect("global profile-backed provider update --from-existing"); - run::provider_update(run::ProviderUpdateOptions { - server: &ts.endpoint, - name: "global-update", - from_existing: true, - from_oidc_token: false, - credentials: &[], - config: &[], - credential_expires_at: &[], - workspace: "default", - tls: &ts.tls, - readiness: run::ProviderWaitOptions::default(), + let provider = ts + .state + .providers + .lock() + .await + .get("global-update") + .cloned() + .expect("global provider should still be stored"); + assert_eq!( + provider.credentials.get("GLOBAL_UPDATE_DISCOVERY_API_KEY"), + Some(&"global-secret".to_string()) + ); + assert!( + !provider + .credentials + .contains_key("WORKSPACE_UPDATE_DISCOVERY_API_KEY") + ); }) - .await - .expect("global profile-backed provider update --from-existing"); - - let provider = ts - .state - .providers - .lock() - .await - .get("global-update") - .cloned() - .expect("global provider should still be stored"); - assert_eq!( - provider.credentials.get("GLOBAL_UPDATE_DISCOVERY_API_KEY"), - Some(&"global-secret".to_string()) - ); - assert!( - !provider - .credentials - .contains_key("WORKSPACE_UPDATE_DISCOVERY_API_KEY") - ); + .await; } #[tokio::test] @@ -4929,7 +4953,7 @@ async fn provider_update_from_oidc_token_preserves_global_profile_scope() { #[tokio::test] async fn provider_profile_import_from_directory_imports_supported_profile_files() { let ts = run_server().await; - let dir = tempfile::tempdir().unwrap(); + let dir = helpers::tempdir().unwrap(); std::fs::write( dir.path().join("custom-yaml.yaml"), r" @@ -5083,7 +5107,7 @@ async fn provider_profile_import_redacts_url_query_from_http_errors() { #[tokio::test] async fn provider_profile_import_preserves_advanced_network_policy_fields() { let ts = run_server().await; - let dir = tempfile::tempdir().unwrap(); + let dir = helpers::tempdir().unwrap(); let profile_path = dir.path().join("advanced-api.yaml"); std::fs::write( &profile_path, @@ -5149,7 +5173,7 @@ binaries: #[tokio::test] async fn provider_profile_import_from_directory_parse_error_prevents_partial_import() { let ts = run_server().await; - let dir = tempfile::tempdir().unwrap(); + let dir = helpers::tempdir().unwrap(); std::fs::write( dir.path().join("custom-good.yaml"), r" @@ -5187,7 +5211,7 @@ endpoints: #[tokio::test] async fn provider_profile_lint_from_directory_reports_parse_errors_without_importing() { let ts = run_server().await; - let dir = tempfile::tempdir().unwrap(); + let dir = helpers::tempdir().unwrap(); std::fs::write( dir.path().join("custom-good.yaml"), r" @@ -5250,27 +5274,30 @@ async fn provider_create_rejects_key_only_credentials_without_local_env_value() #[tokio::test] async fn provider_create_rejects_profileless_generic_type() { let ts = run_server().await; - let _guard = EnvVarGuard::set(&[("NAV_GENERIC_TEST_KEY", "generic-value")]); - let err = run::provider_create( - &ts.endpoint, - "my-generic", - "generic", - false, - &["NAV_GENERIC_TEST_KEY".to_string()], - false, - &[], - "default", - &ts.tls, - ) - .await - .expect_err("profileless generic provider creation should fail"); + Environment::from_pairs(&[("NAV_GENERIC_TEST_KEY", "generic-value")]) + .run_async(async { + let err = run::provider_create( + &ts.endpoint, + "my-generic", + "generic", + false, + &["NAV_GENERIC_TEST_KEY".to_string()], + false, + &[], + "default", + &ts.tls, + ) + .await + .expect_err("profileless generic provider creation should fail"); - assert!( - err.to_string() - .contains("provider profile 'generic' not found"), - "unexpected error: {err}" - ); + assert!( + err.to_string() + .contains("provider profile 'generic' not found"), + "unexpected error: {err}" + ); + }) + .await; } #[tokio::test] @@ -5416,67 +5443,74 @@ async fn provider_create_rejects_combined_from_gcloud_adc_and_credentials() { #[tokio::test] async fn provider_create_rejects_empty_env_var_for_key_only_credential() { let ts = run_server().await; - let _guard = EnvVarGuard::set(&[("NVIDIA_API_KEY", "")]); - let err = run::provider_create( - &ts.endpoint, - "bad-provider", - "nvidia", - false, - &["NVIDIA_API_KEY".to_string()], - false, - &[], - "default", - &ts.tls, - ) - .await - .expect_err("empty env var should be rejected"); + Environment::from_pairs(&[("NVIDIA_API_KEY", "")]) + .run_async(async { + let err = run::provider_create( + &ts.endpoint, + "bad-provider", + "nvidia", + false, + &["NVIDIA_API_KEY".to_string()], + false, + &[], + "default", + &ts.tls, + ) + .await + .expect_err("empty env var should be rejected"); - assert!( - err.to_string() - .contains("requires local env var 'NVIDIA_API_KEY' to be set to a non-empty value"), - "unexpected error: {err}" - ); + assert!( + err.to_string().contains( + "requires local env var 'NVIDIA_API_KEY' to be set to a non-empty value" + ), + "unexpected error: {err}" + ); + }) + .await; } #[tokio::test] async fn provider_create_supports_nvidia_type_with_nvidia_api_key() { let ts = run_server().await; - let _guard = EnvVarGuard::set(&[("NVIDIA_API_KEY", "nvapi-live-test")]); - run::provider_create( - &ts.endpoint, - "my-nvidia", - "nvidia", - false, - &["NVIDIA_API_KEY".to_string()], - false, - &[], - "default", - &ts.tls, - ) - .await - .expect("provider create"); + Environment::from_pairs(&[("NVIDIA_API_KEY", "nvapi-live-test")]) + .run_async(async { + run::provider_create( + &ts.endpoint, + "my-nvidia", + "nvidia", + false, + &["NVIDIA_API_KEY".to_string()], + false, + &[], + "default", + &ts.tls, + ) + .await + .expect("provider create"); - let mut client = openshell_cli::tls::grpc_client(&ts.endpoint, &ts.tls) - .await - .expect("grpc client should connect"); - let response = client - .get_provider(GetProviderRequest { - name: "my-nvidia".to_string(), - workspace_scope: Some(openshell_core::proto::workspace_selector( - "default".to_string(), - )), + let mut client = openshell_cli::tls::grpc_client(&ts.endpoint, &ts.tls) + .await + .expect("grpc client should connect"); + let response = client + .get_provider(GetProviderRequest { + name: "my-nvidia".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector( + "default".to_string(), + )), + }) + .await + .expect("get provider should succeed") + .into_inner(); + let provider = response.provider.expect("provider should exist"); + assert_eq!(provider.r#type, "nvidia"); + assert_eq!( + provider.credentials.get("NVIDIA_API_KEY"), + Some(&"nvapi-live-test".to_string()) + ); }) - .await - .expect("get provider should succeed") - .into_inner(); - let provider = response.provider.expect("provider should exist"); - assert_eq!(provider.r#type, "nvidia"); - assert_eq!( - provider.credentials.get("NVIDIA_API_KEY"), - Some(&"nvapi-live-test".to_string()) - ); + .await; } // ── --from-gcloud-adc tests ─────────────────────────────────────────────────── @@ -5498,74 +5532,77 @@ async fn provider_create_from_gcloud_adc_happy_path() { // Point GOOGLE_APPLICATION_CREDENTIALS at the temp file so read_gcloud_adc // picks it up without touching the real ~/.config/gcloud/ path. let adc_path = adc_file.path().to_str().unwrap().to_string(); - let _guard = EnvVarGuard::set(&[("GOOGLE_APPLICATION_CREDENTIALS", &adc_path)]); - run::provider_create( - &ts.endpoint, - "my-vertex", - "google-vertex-ai", - false, - &[], // no explicit credentials; refresh bootstrap covers it - true, // from_gcloud_adc - &[], - "default", - &ts.tls, - ) - .await - .expect("provider_create with --from-gcloud-adc should succeed"); - - // Provider must exist in the server state. - let providers = ts.state.providers.lock().await; - let provider = providers - .get("my-vertex") - .expect("provider should be stored after create"); - assert_eq!(provider.r#type, "google-vertex-ai"); - assert_eq!( - provider - .credentials - .get("GOOGLE_VERTEX_AI_TOKEN") - .map(String::as_str), - Some("minted-GOOGLE_VERTEX_AI_TOKEN"), - "initial rotate should materialize a usable access token" - ); - drop(providers); + Environment::from_pairs(&[("GOOGLE_APPLICATION_CREDENTIALS", &adc_path)]) + .run_async(async { + run::provider_create( + &ts.endpoint, + "my-vertex", + "google-vertex-ai", + false, + &[], // no explicit credentials; refresh bootstrap covers it + true, // from_gcloud_adc + &[], + "default", + &ts.tls, + ) + .await + .expect("provider_create with --from-gcloud-adc should succeed"); + + // Provider must exist in the server state. + let providers = ts.state.providers.lock().await; + let provider = providers + .get("my-vertex") + .expect("provider should be stored after create"); + assert_eq!(provider.r#type, "google-vertex-ai"); + assert_eq!( + provider + .credentials + .get("GOOGLE_VERTEX_AI_TOKEN") + .map(String::as_str), + Some("minted-GOOGLE_VERTEX_AI_TOKEN"), + "initial rotate should materialize a usable access token" + ); + drop(providers); - // ADC bootstrap must configure refresh and immediately mint the first token. - let requests = ts.state.refresh_requests.lock().await.clone(); - assert_eq!( - requests.len(), - 2, - "expected configure + rotate refresh requests" - ); - assert!(matches!( - &requests[0], - ProviderRefreshRequestLog::Configure { - provider, - credential_key, - expires_at_ms: None, - .. - } if provider == "my-vertex" && credential_key == "GOOGLE_VERTEX_AI_TOKEN" - )); - assert_eq!( - requests[1], - ProviderRefreshRequestLog::Rotate { - provider: "my-vertex".to_string(), - credential_key: "GOOGLE_VERTEX_AI_TOKEN".to_string(), - } - ); + // ADC bootstrap must configure refresh and immediately mint the first token. + let requests = ts.state.refresh_requests.lock().await.clone(); + assert_eq!( + requests.len(), + 2, + "expected configure + rotate refresh requests" + ); + assert!(matches!( + &requests[0], + ProviderRefreshRequestLog::Configure { + provider, + credential_key, + expires_at_ms: None, + .. + } if provider == "my-vertex" && credential_key == "GOOGLE_VERTEX_AI_TOKEN" + )); + assert_eq!( + requests[1], + ProviderRefreshRequestLog::Rotate { + provider: "my-vertex".to_string(), + credential_key: "GOOGLE_VERTEX_AI_TOKEN".to_string(), + } + ); - // The refresh status must record the ADC material keys. - let refresh_statuses = ts.state.refresh_statuses.lock().await; - let status = refresh_statuses - .get(&( - "my-vertex".to_string(), - "GOOGLE_VERTEX_AI_TOKEN".to_string(), - )) - .expect("refresh status should be stored"); - assert_eq!( - status.strategy, - ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32 - ); + // The refresh status must record the ADC material keys. + let refresh_statuses = ts.state.refresh_statuses.lock().await; + let status = refresh_statuses + .get(&( + "my-vertex".to_string(), + "GOOGLE_VERTEX_AI_TOKEN".to_string(), + )) + .expect("refresh status should be stored"); + assert_eq!( + status.strategy, + ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32 + ); + }) + .await; } #[tokio::test] @@ -5584,34 +5621,37 @@ async fn provider_create_from_gcloud_adc_rejects_service_account() { serde_json::to_writer(&adc_file, &adc_content).unwrap(); let adc_path = adc_file.path().to_str().unwrap().to_string(); - let _guard = EnvVarGuard::set(&[("GOOGLE_APPLICATION_CREDENTIALS", &adc_path)]); - let err = run::provider_create( - &ts.endpoint, - "my-vertex-sa", - "google-vertex-ai", - false, - &[], - true, - &[], - "default", - &ts.tls, - ) - .await - .expect_err("service_account ADC should be rejected"); + Environment::from_pairs(&[("GOOGLE_APPLICATION_CREDENTIALS", &adc_path)]) + .run_async(async { + let err = run::provider_create( + &ts.endpoint, + "my-vertex-sa", + "google-vertex-ai", + false, + &[], + true, + &[], + "default", + &ts.tls, + ) + .await + .expect_err("service_account ADC should be rejected"); - assert!( - err.to_string() - .contains("GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN"), - "error should mention the service-account token key, got: {err}" - ); + assert!( + err.to_string() + .contains("GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN"), + "error should mention the service-account token key, got: {err}" + ); - // create_provider must NOT have been called — no provider stored. - let providers = ts.state.providers.lock().await; - assert!( - providers.is_empty(), - "no provider should have been created on pre-flight failure" - ); + // create_provider must NOT have been called — no provider stored. + let providers = ts.state.providers.lock().await; + assert!( + providers.is_empty(), + "no provider should have been created on pre-flight failure" + ); + }) + .await; } #[tokio::test] @@ -5619,39 +5659,42 @@ async fn provider_create_from_gcloud_adc_missing_file() { let ts = run_server().await; // Point to a path that does not exist. - let _guard = EnvVarGuard::set(&[( + + Environment::from_pairs(&[( "GOOGLE_APPLICATION_CREDENTIALS", "/tmp/nonexistent-adc-file-openshell-test.json", - )]); - - let err = run::provider_create( - &ts.endpoint, - "my-vertex-missing", - "google-vertex-ai", - false, - &[], - true, - &[], - "default", - &ts.tls, - ) - .await - .expect_err("missing ADC file should produce an error"); + )]) + .run_async(async { + let err = run::provider_create( + &ts.endpoint, + "my-vertex-missing", + "google-vertex-ai", + false, + &[], + true, + &[], + "default", + &ts.tls, + ) + .await + .expect_err("missing ADC file should produce an error"); - // Error must mention the file path or the read failure. - let msg = err.to_string(); - assert!( - msg.contains("nonexistent-adc-file-openshell-test.json") - || msg.contains("failed to read gcloud ADC file"), - "error should reference the missing file, got: {msg}" - ); + // Error must mention the file path or the read failure. + let msg = err.to_string(); + assert!( + msg.contains("nonexistent-adc-file-openshell-test.json") + || msg.contains("failed to read gcloud ADC file"), + "error should reference the missing file, got: {msg}" + ); - // create_provider must NOT have been called — no provider stored. - let providers = ts.state.providers.lock().await; - assert!( - providers.is_empty(), - "no provider should have been created on pre-flight failure" - ); + // create_provider must NOT have been called — no provider stored. + let providers = ts.state.providers.lock().await; + assert!( + providers.is_empty(), + "no provider should have been created on pre-flight failure" + ); + }) + .await; } #[tokio::test] @@ -5694,38 +5737,41 @@ async fn provider_create_from_gcloud_adc_rolls_back_provider_when_refresh_config let adc_file = tempfile::NamedTempFile::new().unwrap(); serde_json::to_writer(&adc_file, &adc_content).unwrap(); let adc_path = adc_file.path().to_str().unwrap().to_string(); - let _guard = EnvVarGuard::set(&[("GOOGLE_APPLICATION_CREDENTIALS", &adc_path)]); - - let err = run::provider_create( - &ts.endpoint, - "vertex-rollback", - "google-vertex-ai", - false, - &[], - true, - &[], - "default", - &ts.tls, - ) - .await - .expect_err("configure_provider_refresh failure should bubble up"); - assert!( - err.to_string().contains("simulated configure failure"), - "unexpected error: {err}" - ); - assert!( - !ts.state - .providers - .lock() + Environment::from_pairs(&[("GOOGLE_APPLICATION_CREDENTIALS", &adc_path)]) + .run_async(async { + let err = run::provider_create( + &ts.endpoint, + "vertex-rollback", + "google-vertex-ai", + false, + &[], + true, + &[], + "default", + &ts.tls, + ) .await - .contains_key("vertex-rollback"), - "provider should be deleted on rollback" - ); - assert_eq!( - ts.state.delete_provider_requests.lock().await.clone(), - vec!["vertex-rollback".to_string()] - ); + .expect_err("configure_provider_refresh failure should bubble up"); + + assert!( + err.to_string().contains("simulated configure failure"), + "unexpected error: {err}" + ); + assert!( + !ts.state + .providers + .lock() + .await + .contains_key("vertex-rollback"), + "provider should be deleted on rollback" + ); + assert_eq!( + ts.state.delete_provider_requests.lock().await.clone(), + vec!["vertex-rollback".to_string()] + ); + }) + .await; } #[tokio::test] @@ -5745,38 +5791,41 @@ async fn provider_create_from_gcloud_adc_warn_path_keeps_provider_when_rollback_ let adc_file = tempfile::NamedTempFile::new().unwrap(); serde_json::to_writer(&adc_file, &adc_content).unwrap(); let adc_path = adc_file.path().to_str().unwrap().to_string(); - let _guard = EnvVarGuard::set(&[("GOOGLE_APPLICATION_CREDENTIALS", &adc_path)]); - - let err = run::provider_create( - &ts.endpoint, - "vertex-cleanup-warning", - "google-vertex-ai", - false, - &[], - true, - &[], - "default", - &ts.tls, - ) - .await - .expect_err("cleanup failure path should still return configure error"); - assert!( - err.to_string().contains("simulated configure failure"), - "unexpected error: {err}" - ); - assert!( - ts.state - .providers - .lock() + Environment::from_pairs(&[("GOOGLE_APPLICATION_CREDENTIALS", &adc_path)]) + .run_async(async { + let err = run::provider_create( + &ts.endpoint, + "vertex-cleanup-warning", + "google-vertex-ai", + false, + &[], + true, + &[], + "default", + &ts.tls, + ) .await - .contains_key("vertex-cleanup-warning"), - "provider should remain when rollback deletion fails" - ); - assert_eq!( - ts.state.delete_provider_requests.lock().await.clone(), - vec!["vertex-cleanup-warning".to_string()] - ); + .expect_err("cleanup failure path should still return configure error"); + + assert!( + err.to_string().contains("simulated configure failure"), + "unexpected error: {err}" + ); + assert!( + ts.state + .providers + .lock() + .await + .contains_key("vertex-cleanup-warning"), + "provider should remain when rollback deletion fails" + ); + assert_eq!( + ts.state.delete_provider_requests.lock().await.clone(), + vec!["vertex-cleanup-warning".to_string()] + ); + }) + .await; } #[tokio::test] @@ -5794,74 +5843,81 @@ async fn provider_create_from_gcloud_adc_rolls_back_provider_when_initial_rotate let adc_file = tempfile::NamedTempFile::new().unwrap(); serde_json::to_writer(&adc_file, &adc_content).unwrap(); let adc_path = adc_file.path().to_str().unwrap().to_string(); - let _guard = EnvVarGuard::set(&[("GOOGLE_APPLICATION_CREDENTIALS", &adc_path)]); - let err = run::provider_create( - &ts.endpoint, - "vertex-rotate-rollback", - "google-vertex-ai", - false, - &[], - true, - &[], - "default", - &ts.tls, - ) - .await - .expect_err("initial rotate failure should roll back the provider"); - - assert!( - err.to_string().contains("simulated rotate failure"), - "unexpected error: {err}" - ); - assert!( - !ts.state - .providers - .lock() + Environment::from_pairs(&[("GOOGLE_APPLICATION_CREDENTIALS", &adc_path)]) + .run_async(async { + let err = run::provider_create( + &ts.endpoint, + "vertex-rotate-rollback", + "google-vertex-ai", + false, + &[], + true, + &[], + "default", + &ts.tls, + ) .await - .contains_key("vertex-rotate-rollback"), - "provider should be deleted on initial-rotate rollback" - ); - assert_eq!( - ts.state.delete_provider_requests.lock().await.clone(), - vec!["vertex-rotate-rollback".to_string()] - ); + .expect_err("initial rotate failure should roll back the provider"); + + assert!( + err.to_string().contains("simulated rotate failure"), + "unexpected error: {err}" + ); + assert!( + !ts.state + .providers + .lock() + .await + .contains_key("vertex-rotate-rollback"), + "provider should be deleted on initial-rotate rollback" + ); + assert_eq!( + ts.state.delete_provider_requests.lock().await.clone(), + vec!["vertex-rotate-rollback".to_string()] + ); + }) + .await; } #[tokio::test] async fn provider_create_from_existing_vertex_config_only_reports_missing_vertex_credentials() { let ts = run_server().await; - let _env = EnvVarGuard::set(&[ + + Environment::from_pairs(&[ ("VERTEX_AI_PROJECT_ID", "vertex-config-only-project"), ("VERTEX_AI_REGION", "us-central1"), - ]); - - let err = run::provider_create( - &ts.endpoint, - "vertex-config-only", - "google-vertex-ai", - true, - &[], - false, - &[], - "default", - &ts.tls, - ) - .await - .expect_err("config-only discovery should surface missing credential guidance"); + ]) + .run_async(async { + let err = run::provider_create( + &ts.endpoint, + "vertex-config-only", + "google-vertex-ai", + true, + &[], + false, + &[], + "default", + &ts.tls, + ) + .await + .expect_err("config-only discovery should surface missing credential guidance"); - let msg = err.to_string(); - assert!( - msg.contains("GOOGLE_VERTEX_AI_TOKEN") && msg.contains("VERTEX_AI_SERVICE_ACCOUNT_TOKEN"), - "unexpected error: {msg}" - ); - assert!( - !ts.state - .providers - .lock() - .await - .contains_key("vertex-config-only") - ); + let msg = err.to_string(); + assert!( + msg.contains("GOOGLE_VERTEX_AI_TOKEN") + && msg.contains("VERTEX_AI_SERVICE_ACCOUNT_TOKEN"), + "unexpected error: {msg}" + ); + assert!( + !ts.state + .providers + .lock() + .await + .contains_key("vertex-config-only") + ); + }) + .await; } #[tokio::test] @@ -5878,69 +5934,72 @@ async fn provider_create_from_gcloud_adc_with_config_keys() { let adc_file = tempfile::NamedTempFile::new().unwrap(); serde_json::to_writer(&adc_file, &adc_content).unwrap(); let adc_path = adc_file.path().to_str().unwrap().to_string(); - let _guard = EnvVarGuard::set(&[("GOOGLE_APPLICATION_CREDENTIALS", &adc_path)]); - run::provider_create( - &ts.endpoint, - "vertex-with-config", - "google-vertex-ai", - false, - &[], // no explicit credentials; ADC flow - true, // from_gcloud_adc - &[ - "VERTEX_AI_PROJECT_ID=my-gcp-project".to_string(), - "VERTEX_AI_REGION=us-east1".to_string(), - ], - "default", - &ts.tls, - ) - .await - .expect("provider_create with --from-gcloud-adc and --config keys should succeed"); - - // Verify provider was created with the config keys. - let providers = ts.state.providers.lock().await; - let provider = providers - .get("vertex-with-config") - .expect("provider should be stored after create"); - assert_eq!(provider.r#type, "google-vertex-ai"); - assert_eq!( - provider - .config - .get("VERTEX_AI_PROJECT_ID") - .map(String::as_str), - Some("my-gcp-project"), - "VERTEX_AI_PROJECT_ID must be stored in provider config" - ); - assert_eq!( - provider.config.get("VERTEX_AI_REGION").map(String::as_str), - Some("us-east1"), - "VERTEX_AI_REGION must be stored in provider config" - ); - drop(providers); + Environment::from_pairs(&[("GOOGLE_APPLICATION_CREDENTIALS", &adc_path)]) + .run_async(async { + run::provider_create( + &ts.endpoint, + "vertex-with-config", + "google-vertex-ai", + false, + &[], // no explicit credentials; ADC flow + true, // from_gcloud_adc + &[ + "VERTEX_AI_PROJECT_ID=my-gcp-project".to_string(), + "VERTEX_AI_REGION=us-east1".to_string(), + ], + "default", + &ts.tls, + ) + .await + .expect("provider_create with --from-gcloud-adc and --config keys should succeed"); + + // Verify provider was created with the config keys. + let providers = ts.state.providers.lock().await; + let provider = providers + .get("vertex-with-config") + .expect("provider should be stored after create"); + assert_eq!(provider.r#type, "google-vertex-ai"); + assert_eq!( + provider + .config + .get("VERTEX_AI_PROJECT_ID") + .map(String::as_str), + Some("my-gcp-project"), + "VERTEX_AI_PROJECT_ID must be stored in provider config" + ); + assert_eq!( + provider.config.get("VERTEX_AI_REGION").map(String::as_str), + Some("us-east1"), + "VERTEX_AI_REGION must be stored in provider config" + ); + drop(providers); - // ADC flow should configure refresh and eagerly mint the initial token. - let refresh_requests = ts.state.refresh_requests.lock().await.clone(); - assert_eq!( - refresh_requests.len(), - 2, - "exactly one configure call and one rotate call expected" - ); - assert!(matches!( - &refresh_requests[0], - ProviderRefreshRequestLog::Configure { - provider, - credential_key, - expires_at_ms: None, - .. - } if provider == "vertex-with-config" && credential_key == "GOOGLE_VERTEX_AI_TOKEN" - )); - assert_eq!( - refresh_requests[1], - ProviderRefreshRequestLog::Rotate { - provider: "vertex-with-config".to_string(), - credential_key: "GOOGLE_VERTEX_AI_TOKEN".to_string(), - } - ); + // ADC flow should configure refresh and eagerly mint the initial token. + let refresh_requests = ts.state.refresh_requests.lock().await.clone(); + assert_eq!( + refresh_requests.len(), + 2, + "exactly one configure call and one rotate call expected" + ); + assert!(matches!( + &refresh_requests[0], + ProviderRefreshRequestLog::Configure { + provider, + credential_key, + expires_at_ms: None, + .. + } if provider == "vertex-with-config" && credential_key == "GOOGLE_VERTEX_AI_TOKEN" + )); + assert_eq!( + refresh_requests[1], + ProviderRefreshRequestLog::Rotate { + provider: "vertex-with-config".to_string(), + credential_key: "GOOGLE_VERTEX_AI_TOKEN".to_string(), + } + ); + }) + .await; } #[tokio::test] @@ -5956,34 +6015,37 @@ async fn provider_create_from_gcloud_adc_missing_refresh_token() { let adc_file = tempfile::NamedTempFile::new().unwrap(); serde_json::to_writer(&adc_file, &adc_content).unwrap(); let adc_path = adc_file.path().to_str().unwrap().to_string(); - let _guard = EnvVarGuard::set(&[("GOOGLE_APPLICATION_CREDENTIALS", &adc_path)]); - let err = run::provider_create( - &ts.endpoint, - "vertex-missing-refresh", - "google-vertex-ai", - false, - &[], - true, - &[], - "default", - &ts.tls, - ) - .await - .expect_err("missing refresh_token should produce an error"); + Environment::from_pairs(&[("GOOGLE_APPLICATION_CREDENTIALS", &adc_path)]) + .run_async(async { + let err = run::provider_create( + &ts.endpoint, + "vertex-missing-refresh", + "google-vertex-ai", + false, + &[], + true, + &[], + "default", + &ts.tls, + ) + .await + .expect_err("missing refresh_token should produce an error"); - let err_msg = err.to_string(); - assert!( - err_msg.contains("refresh_token"), - "error must mention 'refresh_token', got: {err_msg}" - ); + let err_msg = err.to_string(); + assert!( + err_msg.contains("refresh_token"), + "error must mention 'refresh_token', got: {err_msg}" + ); - // No provider should have been created. - let providers = ts.state.providers.lock().await; - assert!( - providers.is_empty(), - "no provider must be created when ADC validation fails" - ); + // No provider should have been created. + let providers = ts.state.providers.lock().await; + assert!( + providers.is_empty(), + "no provider must be created when ADC validation fails" + ); + }) + .await; } #[tokio::test] @@ -5999,32 +6061,35 @@ async fn provider_create_from_gcloud_adc_missing_client_secret() { let adc_file = tempfile::NamedTempFile::new().unwrap(); serde_json::to_writer(&adc_file, &adc_content).unwrap(); let adc_path = adc_file.path().to_str().unwrap().to_string(); - let _guard = EnvVarGuard::set(&[("GOOGLE_APPLICATION_CREDENTIALS", &adc_path)]); - let err = run::provider_create( - &ts.endpoint, - "vertex-missing-secret", - "google-vertex-ai", - false, - &[], - true, - &[], - "default", - &ts.tls, - ) - .await - .expect_err("missing client_secret should produce an error"); + Environment::from_pairs(&[("GOOGLE_APPLICATION_CREDENTIALS", &adc_path)]) + .run_async(async { + let err = run::provider_create( + &ts.endpoint, + "vertex-missing-secret", + "google-vertex-ai", + false, + &[], + true, + &[], + "default", + &ts.tls, + ) + .await + .expect_err("missing client_secret should produce an error"); - let err_msg = err.to_string(); - assert!( - err_msg.contains("client_secret"), - "error must mention 'client_secret', got: {err_msg}" - ); + let err_msg = err.to_string(); + assert!( + err_msg.contains("client_secret"), + "error must mention 'client_secret', got: {err_msg}" + ); - // No provider should have been created. - let providers = ts.state.providers.lock().await; - assert!( - providers.is_empty(), - "no provider must be created when ADC validation fails" - ); + // No provider should have been created. + let providers = ts.state.providers.lock().await; + assert!( + providers.is_empty(), + "no provider must be created when ADC validation fails" + ); + }) + .await; } diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index f71108691e..44ddc5ebe7 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -5,7 +5,8 @@ mod helpers; -use helpers::{EnvVarGuard, build_ca, build_client_cert, build_server_cert}; +use helpers::TempDir; +use helpers::{Environment, build_ca, build_client_cert, build_server_cert}; use openshell_bootstrap::load_last_sandbox; use openshell_cli::run; use openshell_cli::tls::TlsOptions; @@ -37,7 +38,6 @@ use std::os::unix::fs::PermissionsExt; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::time::{Duration, Instant}; -use tempfile::TempDir; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use tokio::sync::{Mutex, Notify, mpsc}; @@ -1251,7 +1251,7 @@ async fn run_server() -> TestServer { .unwrap(); }); - let dir = tempfile::tempdir().unwrap(); + let dir = helpers::tempdir().unwrap(); let ca_path = dir.path().join("ca.crt"); let cert_path = dir.path().join("tls.crt"); let key_path = dir.path().join("tls.key"); @@ -1560,7 +1560,7 @@ exec env OPENSHELL_FAKE_FORWARD_MODE=sleep "$helper" -N -o "ProxyCommand=/tmp/op FakeUnreachableForward { log_path, pid_path } } -fn test_env(fake_ssh_dir: &TempDir, xdg_dir: &TempDir) -> EnvVarGuard { +fn test_env(fake_ssh_dir: &TempDir, xdg_dir: &TempDir) -> Environment { test_env_with(fake_ssh_dir, xdg_dir, &[]) } @@ -1568,7 +1568,7 @@ fn test_env_with( fake_ssh_dir: &TempDir, xdg_dir: &TempDir, extra: &[(&'static str, String)], -) -> EnvVarGuard { +) -> Environment { let path = format!( "{}:{}", fake_ssh_dir.path().display(), @@ -1587,7 +1587,7 @@ fn test_env_with( .map(|(key, value)| (*key, value.as_str())) .collect::>(); - EnvVarGuard::set(&pairs) + Environment::from_pairs(&pairs) } async fn deleted_names(server: &TestServer) -> Vec> { @@ -1731,654 +1731,719 @@ async fn sandbox_create_tolerates_an_unreachable_profile_catalog() { // Nothing derives provider authority from it: a provider is attached only // when the user names one. let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); - - server - .openshell - .state - .fail_list_provider_profiles - .store(true, Ordering::SeqCst); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); - run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("catalog-unavailable"), - command: &["claude".into()], - ..test_config() - }, - "default", - &tls, - ) - .await - .expect("an unreachable catalog must not block sandbox creation"); + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); - let requests = server.openshell.state.create_requests.lock().await; - assert_eq!(requests.len(), 1, "the sandbox should still be created"); - assert!( - requests[0] - .spec - .as_ref() - .is_none_or(|spec| spec.providers.is_empty()), - "no provider should be attached without an explicit --provider" - ); + server + .openshell + .state + .fail_list_provider_profiles + .store(true, Ordering::SeqCst); + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("catalog-unavailable"), + command: &["claude".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("an unreachable catalog must not block sandbox creation"); + + let requests = server.openshell.state.create_requests.lock().await; + assert_eq!(requests.len(), 1, "the sandbox should still be created"); + assert!( + requests[0] + .spec + .as_ref() + .is_none_or(|spec| spec.providers.is_empty()), + "no provider should be attached without an explicit --provider" + ); + }) + .await; } #[tokio::test] async fn sandbox_create_keeps_command_sessions_by_default() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); - - run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("default-command"), - command: &["echo".into(), "OK".into()], - ..test_config() - }, - "default", - &tls, - ) - .await - .expect("sandbox create should succeed"); - - assert!(deleted_names(&server).await.is_empty()); - assert_eq!( - load_last_sandbox("openshell", "default").as_deref(), - Some("default-command"), - "default sandboxes should be persisted as last-used" - ); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("default-command"), + command: &["echo".into(), "OK".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("sandbox create should succeed"); + + assert!(deleted_names(&server).await.is_empty()); + assert_eq!( + load_last_sandbox("openshell", "default").as_deref(), + Some("default-command"), + "default sandboxes should be persisted as last-used" + ); + }) + .await; } #[tokio::test] async fn sandbox_create_without_inferred_provider_skips_gateway_config() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); - - run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("no-provider-config"), - command: &["echo".into(), "OK".into()], - ..test_config() - }, - "default", - &tls, - ) - .await - .expect("sandbox create should succeed without reading gateway config"); - - assert_eq!( - server - .openshell - .state - .gateway_config_requests - .load(Ordering::SeqCst), - 0, - "commands without an inferred provider must not require global gateway settings" - ); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("no-provider-config"), + command: &["echo".into(), "OK".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("sandbox create should succeed without reading gateway config"); + + assert_eq!( + server + .openshell + .state + .gateway_config_requests + .load(Ordering::SeqCst), + 0, + "commands without an inferred provider must not require global gateway settings" + ); + }) + .await; } #[tokio::test] async fn sandbox_create_sends_cpu_and_memory_limits_only() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("resources"), + cpu: Some("500m"), + memory: Some("2Gi"), + command: &["echo".into(), "OK".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("sandbox create should succeed"); - run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("resources"), - cpu: Some("500m"), - memory: Some("2Gi"), - command: &["echo".into(), "OK".into()], - ..test_config() - }, - "default", - &tls, - ) - .await - .expect("sandbox create should succeed"); - - let requests = create_requests(&server).await; - let resources = requests[0] - .spec - .as_ref() - .and_then(|spec| spec.template.as_ref()) - .and_then(|template| template.resources.as_ref()) - .expect("resource limits should be sent"); - let limits = resources - .fields - .get("limits") - .and_then(|value| value.kind.as_ref()) - .and_then(|kind| match kind { - prost_types::value::Kind::StructValue(inner) => Some(inner), - _ => None, + let requests = create_requests(&server).await; + let resources = requests[0] + .spec + .as_ref() + .and_then(|spec| spec.template.as_ref()) + .and_then(|template| template.resources.as_ref()) + .expect("resource limits should be sent"); + let limits = resources + .fields + .get("limits") + .and_then(|value| value.kind.as_ref()) + .and_then(|kind| match kind { + prost_types::value::Kind::StructValue(inner) => Some(inner), + _ => None, + }) + .expect("limits should be a struct"); + + assert_eq!( + limits + .fields + .get("cpu") + .and_then(|value| value.kind.as_ref()) + .and_then(|kind| match kind { + prost_types::value::Kind::StringValue(value) => Some(value.as_str()), + _ => None, + }), + Some("500m") + ); + assert_eq!( + limits + .fields + .get("memory") + .and_then(|value| value.kind.as_ref()) + .and_then(|kind| match kind { + prost_types::value::Kind::StringValue(value) => Some(value.as_str()), + _ => None, + }), + Some("2Gi") + ); + assert!(!resources.fields.contains_key("requests")); }) - .expect("limits should be a struct"); - - assert_eq!( - limits - .fields - .get("cpu") - .and_then(|value| value.kind.as_ref()) - .and_then(|kind| match kind { - prost_types::value::Kind::StringValue(value) => Some(value.as_str()), - _ => None, - }), - Some("500m") - ); - assert_eq!( - limits - .fields - .get("memory") - .and_then(|value| value.kind.as_ref()) - .and_then(|kind| match kind { - prost_types::value::Kind::StringValue(value) => Some(value.as_str()), - _ => None, - }), - Some("2Gi") - ); - assert!(!resources.fields.contains_key("requests")); + .await; } #[tokio::test] async fn sandbox_create_persists_exact_trailing_argv_as_main_process() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); - let command = vec![ - "/opt/agent binary".to_string(), - "--prompt=keep spaces".to_string(), - "literal * $HOME".to_string(), - ]; - - run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("canonical-main"), - command: &command, - tty_override: Some(false), - ..test_config() - }, - "default", - &tls, - ) - .await - .expect("sandbox create should succeed"); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + let command = vec![ + "/opt/agent binary".to_string(), + "--prompt=keep spaces".to_string(), + "literal * $HOME".to_string(), + ]; + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("canonical-main"), + command: &command, + tty_override: Some(false), + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("sandbox create should succeed"); - let requests = create_requests(&server).await; - let spec = requests[0] - .spec - .as_ref() - .expect("sandbox spec should be persisted at create time"); - assert_eq!(spec.command, command); - assert!(!spec.tty); - assert!(requests[0].await_main_process_attachment); + let requests = create_requests(&server).await; + let spec = requests[0] + .spec + .as_ref() + .expect("sandbox spec should be persisted at create time"); + assert_eq!(spec.command, command); + assert!(!spec.tty); + assert!(requests[0].await_main_process_attachment); + }) + .await; } #[tokio::test] async fn detached_command_does_not_declare_main_process_attachment() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); - - run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("detached-main"), - command: &["echo".into(), "OK".into()], - detach: true, - ..test_config() - }, - "default", - &tls, - ) - .await - .expect("detached sandbox create should succeed"); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("detached-main"), + command: &["echo".into(), "OK".into()], + detach: true, + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("detached sandbox create should succeed"); - let requests = create_requests(&server).await; - assert!(!requests[0].await_main_process_attachment); + let requests = create_requests(&server).await; + assert!(!requests[0].await_main_process_attachment); + }) + .await; } #[tokio::test] async fn detached_ephemeral_command_delegates_cleanup_to_gateway() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); - - run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("detached-ephemeral-main"), - keep: false, - command: &["worker".into()], - detach: true, - ..test_config() - }, - "default", - &tls, - ) - .await - .expect("detached ephemeral sandbox create should succeed"); - - let requests = create_requests(&server).await; - assert!(!requests[0].await_main_process_attachment); - assert_eq!( - requests[0] - .annotations - .get("openshell.nvidia.com/retention") - .map(String::as_str), - Some("ephemeral") - ); - assert!( - deleted_names(&server).await.is_empty(), - "the gateway owns cleanup after a detached canonical process exits" - ); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("detached-ephemeral-main"), + keep: false, + command: &["worker".into()], + detach: true, + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("detached ephemeral sandbox create should succeed"); + + let requests = create_requests(&server).await; + assert!(!requests[0].await_main_process_attachment); + assert_eq!( + requests[0] + .annotations + .get("openshell.nvidia.com/retention") + .map(String::as_str), + Some("ephemeral") + ); + assert!( + deleted_names(&server).await.is_empty(), + "the gateway owns cleanup after a detached canonical process exits" + ); + }) + .await; } #[tokio::test] async fn sandbox_create_sends_driver_config_json() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("driver-config"), + driver_config_json: Some( + r#"{"kubernetes":{"pod":{"priority_class_name":"batch-low"}}}"#, + ), + command: &["echo".into(), "OK".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("sandbox create should succeed"); - run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("driver-config"), - driver_config_json: Some( - r#"{"kubernetes":{"pod":{"priority_class_name":"batch-low"}}}"#, - ), - command: &["echo".into(), "OK".into()], - ..test_config() - }, - "default", - &tls, - ) - .await - .expect("sandbox create should succeed"); - - let requests = create_requests(&server).await; - let driver_config = requests[0] - .spec - .as_ref() - .and_then(|spec| spec.template.as_ref()) - .and_then(|template| template.driver_config.as_ref()) - .expect("driver config should be sent"); - let kubernetes = driver_config - .fields - .get("kubernetes") - .and_then(|value| value.kind.as_ref()) - .and_then(|kind| match kind { - prost_types::value::Kind::StructValue(inner) => Some(inner), - _ => None, - }) - .expect("kubernetes block should be a struct"); - let pod = kubernetes - .fields - .get("pod") - .and_then(|value| value.kind.as_ref()) - .and_then(|kind| match kind { - prost_types::value::Kind::StructValue(inner) => Some(inner), - _ => None, + let requests = create_requests(&server).await; + let driver_config = requests[0] + .spec + .as_ref() + .and_then(|spec| spec.template.as_ref()) + .and_then(|template| template.driver_config.as_ref()) + .expect("driver config should be sent"); + let kubernetes = driver_config + .fields + .get("kubernetes") + .and_then(|value| value.kind.as_ref()) + .and_then(|kind| match kind { + prost_types::value::Kind::StructValue(inner) => Some(inner), + _ => None, + }) + .expect("kubernetes block should be a struct"); + let pod = kubernetes + .fields + .get("pod") + .and_then(|value| value.kind.as_ref()) + .and_then(|kind| match kind { + prost_types::value::Kind::StructValue(inner) => Some(inner), + _ => None, + }) + .expect("pod block should be a struct"); + + assert_eq!( + pod.fields + .get("priority_class_name") + .and_then(|value| value.kind.as_ref()) + .and_then(|kind| match kind { + prost_types::value::Kind::StringValue(value) => Some(value.as_str()), + _ => None, + }), + Some("batch-low") + ); }) - .expect("pod block should be a struct"); - - assert_eq!( - pod.fields - .get("priority_class_name") - .and_then(|value| value.kind.as_ref()) - .and_then(|kind| match kind { - prost_types::value::Kind::StringValue(value) => Some(value.as_str()), - _ => None, - }), - Some("batch-low") - ); + .await; } #[tokio::test] async fn sandbox_create_with_template_sends_workload_template_name() { let server = run_server().await; add_provider(&server, "github", "github").await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); - - run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("from-template"), - template: Some("gpu-kata"), - providers: &["github".to_string()], - command: &["echo".into(), "OK".into()], - ..test_config() - }, - "default", - &tls, - ) - .await - .expect("sandbox create should succeed"); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("from-template"), + template: Some("gpu-kata"), + providers: &["github".to_string()], + command: &["echo".into(), "OK".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("sandbox create should succeed"); - let requests = create_requests(&server).await; - let request = requests.first().expect("create request should be recorded"); - assert_eq!(request.workload_template, "gpu-kata"); - let spec = request - .spec - .as_ref() - .expect("governance spec should be sent"); - assert_eq!(spec.providers, vec!["github".to_string()]); - assert!(spec.template.is_none()); - assert!(spec.environment.is_empty()); - assert!(spec.resource_requirements.is_none()); + let requests = create_requests(&server).await; + let request = requests.first().expect("create request should be recorded"); + assert_eq!(request.workload_template, "gpu-kata"); + let spec = request + .spec + .as_ref() + .expect("governance spec should be sent"); + assert_eq!(spec.providers, vec!["github".to_string()]); + assert!(spec.template.is_none()); + assert!(spec.environment.is_empty()); + assert!(spec.resource_requirements.is_none()); + }) + .await; } #[tokio::test] async fn sandbox_template_create_sends_non_default_workspace_in_scope_and_metadata() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - - run::sandbox_template_create( - &server.endpoint, - "gpu-kata", - Some("registry.example.com/agent:latest"), - Some("2"), - Some("4Gi"), - Some(GpuResourceRequirements { count: Some(1) }), - Some(r#"{"kubernetes":{"pod":{"node_selector":{"pool":"gpu"}}}}"#), - Some("5m"), - Some(3), - HashMap::from([("team".to_string(), "runtime".to_string())]), - HashMap::from([("owner".to_string(), "platform".to_string())]), - HashMap::from([("FEATURE_FLAG".to_string(), "on".to_string())]), - "table", - "team-a", - &tls, - true, - ) - .await - .expect("template create should succeed"); - - let requests = template_create_requests(&server).await; - let request = requests - .first() - .expect("template create request should be recorded"); - assert_eq!(selected_workspace(&request.workspace_scope), Some("team-a")); - let template = request.template.as_ref().expect("template should be sent"); - let metadata = template.metadata.as_ref().expect("metadata should be sent"); - assert_eq!(metadata.name, "gpu-kata"); - assert_eq!(metadata.workspace, "team-a"); - assert_eq!(metadata.labels.get("team"), Some(&"runtime".to_string())); - assert_eq!( - metadata.annotations.get("owner"), - Some(&"platform".to_string()) - ); - - let spec = template.spec.as_ref().expect("spec should be sent"); - let workload = spec.workload.as_ref().expect("workload should be sent"); - assert_eq!(workload.image, "registry.example.com/agent:latest"); - assert_eq!( - workload.environment.get("FEATURE_FLAG"), - Some(&"on".to_string()) - ); - let resources = workload - .resources - .as_ref() - .expect("resources should be sent"); - assert_eq!(resources.cpu, "2"); - assert_eq!(resources.memory, "4Gi"); - assert_eq!(resources.gpu.as_ref().and_then(|gpu| gpu.count), Some(1)); - assert!(spec.driver_config.is_some()); - let startup = spec - .desired_service_level - .as_ref() - .and_then(|service_level| service_level.startup.as_ref()) - .expect("startup service level should be sent"); - assert_eq!(startup.max_burst, 3); - assert_eq!( - startup - .ready_within - .as_ref() - .map(|duration| duration.seconds), - Some(300) - ); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + + run::sandbox_template_create( + &server.endpoint, + "gpu-kata", + Some("registry.example.com/agent:latest"), + Some("2"), + Some("4Gi"), + Some(GpuResourceRequirements { count: Some(1) }), + Some(r#"{"kubernetes":{"pod":{"node_selector":{"pool":"gpu"}}}}"#), + Some("5m"), + Some(3), + HashMap::from([("team".to_string(), "runtime".to_string())]), + HashMap::from([("owner".to_string(), "platform".to_string())]), + HashMap::from([("FEATURE_FLAG".to_string(), "on".to_string())]), + "table", + "team-a", + &tls, + true, + ) + .await + .expect("template create should succeed"); + + let requests = template_create_requests(&server).await; + let request = requests + .first() + .expect("template create request should be recorded"); + assert_eq!(selected_workspace(&request.workspace_scope), Some("team-a")); + let template = request.template.as_ref().expect("template should be sent"); + let metadata = template.metadata.as_ref().expect("metadata should be sent"); + assert_eq!(metadata.name, "gpu-kata"); + assert_eq!(metadata.workspace, "team-a"); + assert_eq!(metadata.labels.get("team"), Some(&"runtime".to_string())); + assert_eq!( + metadata.annotations.get("owner"), + Some(&"platform".to_string()) + ); + + let spec = template.spec.as_ref().expect("spec should be sent"); + let workload = spec.workload.as_ref().expect("workload should be sent"); + assert_eq!(workload.image, "registry.example.com/agent:latest"); + assert_eq!( + workload.environment.get("FEATURE_FLAG"), + Some(&"on".to_string()) + ); + let resources = workload + .resources + .as_ref() + .expect("resources should be sent"); + assert_eq!(resources.cpu, "2"); + assert_eq!(resources.memory, "4Gi"); + assert_eq!(resources.gpu.as_ref().and_then(|gpu| gpu.count), Some(1)); + assert!(spec.driver_config.is_some()); + let startup = spec + .desired_service_level + .as_ref() + .and_then(|service_level| service_level.startup.as_ref()) + .expect("startup service level should be sent"); + assert_eq!(startup.max_burst, 3); + assert_eq!( + startup + .ready_within + .as_ref() + .map(|duration| duration.seconds), + Some(300) + ); + }) + .await; } #[tokio::test] async fn sandbox_template_list_and_delete_send_workspace_requests() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - - run::sandbox_template_list( - &server.endpoint, - 25, - "next-template-page", - Some("team=runtime"), - false, - "table", - "default", - false, - &tls, - ) - .await - .expect("template list should succeed"); - run::sandbox_template_delete(&server.endpoint, &["gpu-kata".to_string()], "default", &tls) - .await - .expect("template delete should succeed"); - - let list_requests = template_list_requests(&server).await; - let list_request = list_requests - .first() - .expect("template list request should be recorded"); - assert_eq!(list_request.page_size, 25); - assert_eq!(list_request.page_token, "next-template-page"); - assert_eq!(list_request.label_selector, "team=runtime"); - assert_eq!( - selected_workspace(&list_request.workspace_scope), - Some("default") - ); - - let delete_requests = template_delete_requests(&server).await; - let delete_request = delete_requests - .first() - .expect("template delete request should be recorded"); - assert_eq!(delete_request.name, "gpu-kata"); - assert_eq!( - selected_workspace(&delete_request.workspace_scope), - Some("default") - ); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + + run::sandbox_template_list( + &server.endpoint, + 25, + "next-template-page", + Some("team=runtime"), + false, + "table", + "default", + false, + &tls, + ) + .await + .expect("template list should succeed"); + run::sandbox_template_delete( + &server.endpoint, + &["gpu-kata".to_string()], + "default", + &tls, + ) + .await + .expect("template delete should succeed"); + + let list_requests = template_list_requests(&server).await; + let list_request = list_requests + .first() + .expect("template list request should be recorded"); + assert_eq!(list_request.page_size, 25); + assert_eq!(list_request.page_token, "next-template-page"); + assert_eq!(list_request.label_selector, "team=runtime"); + assert_eq!( + selected_workspace(&list_request.workspace_scope), + Some("default") + ); + + let delete_requests = template_delete_requests(&server).await; + let delete_request = delete_requests + .first() + .expect("template delete request should be recorded"); + assert_eq!(delete_request.name, "gpu-kata"); + assert_eq!( + selected_workspace(&delete_request.workspace_scope), + Some("default") + ); + }) + .await; } #[tokio::test] async fn sandbox_template_create_allows_omitted_image() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - - run::sandbox_template_create( - &server.endpoint, - "base", - None, - None, - None, - None, - None, - None, - None, - HashMap::new(), - HashMap::new(), - HashMap::new(), - "table", - "default", - &tls, - true, - ) - .await - .expect("template create without image should succeed"); - - let requests = template_create_requests(&server).await; - let request = requests - .first() - .expect("template create request should be recorded"); - let workload = request - .template - .as_ref() - .and_then(|template| template.spec.as_ref()) - .and_then(|spec| spec.workload.as_ref()) - .expect("workload should be sent"); - assert_eq!(workload.image, ""); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + + run::sandbox_template_create( + &server.endpoint, + "base", + None, + None, + None, + None, + None, + None, + None, + HashMap::new(), + HashMap::new(), + HashMap::new(), + "table", + "default", + &tls, + true, + ) + .await + .expect("template create without image should succeed"); + + let requests = template_create_requests(&server).await; + let request = requests + .first() + .expect("template create request should be recorded"); + let workload = request + .template + .as_ref() + .and_then(|template| template.spec.as_ref()) + .and_then(|spec| spec.workload.as_ref()) + .expect("workload should be sent"); + assert_eq!(workload.image, ""); + }) + .await; } #[tokio::test] async fn sandbox_create_sends_gpu_default_request() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); - - run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("gpu-default"), - gpu_requirements: Some(gpu_requirements(None)), - command: &["echo".into(), "OK".into()], - ..test_config() - }, - "default", - &tls, - ) - .await - .expect("sandbox create should succeed"); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("gpu-default"), + gpu_requirements: Some(gpu_requirements(None)), + command: &["echo".into(), "OK".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("sandbox create should succeed"); - let requests = create_requests(&server).await; - let gpu = requests[0] - .spec - .as_ref() - .and_then(|spec| spec.resource_requirements.as_ref()) - .and_then(|requirements| requirements.gpu.as_ref()) - .expect("GPU requirement should be sent"); + let requests = create_requests(&server).await; + let gpu = requests[0] + .spec + .as_ref() + .and_then(|spec| spec.resource_requirements.as_ref()) + .and_then(|requirements| requirements.gpu.as_ref()) + .expect("GPU requirement should be sent"); - assert_eq!(gpu.count, None); + assert_eq!(gpu.count, None); + }) + .await; } #[tokio::test] async fn sandbox_create_sends_gpu_count_request() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); - - run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("gpu-two"), - gpu_requirements: Some(gpu_requirements(Some(2))), - command: &["echo".into(), "OK".into()], - ..test_config() - }, - "default", - &tls, - ) - .await - .expect("sandbox create should succeed"); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("gpu-two"), + gpu_requirements: Some(gpu_requirements(Some(2))), + command: &["echo".into(), "OK".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("sandbox create should succeed"); - let requests = create_requests(&server).await; - let gpu = requests[0] - .spec - .as_ref() - .and_then(|spec| spec.resource_requirements.as_ref()) - .and_then(|requirements| requirements.gpu.as_ref()) - .expect("GPU requirement should be sent"); + let requests = create_requests(&server).await; + let gpu = requests[0] + .spec + .as_ref() + .and_then(|spec| spec.resource_requirements.as_ref()) + .and_then(|requirements| requirements.gpu.as_ref()) + .expect("GPU requirement should be sent"); - assert_eq!(gpu.count, Some(2)); + assert_eq!(gpu.count, Some(2)); + }) + .await; } #[tokio::test] async fn sandbox_create_skips_inferred_provider_without_local_credentials() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); - - run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("no-inferred-provider"), - command: &["claude".into(), "--version".into()], - tty_override: Some(true), - ..test_config() - }, - "default", - &tls, - ) - .await - .expect("sandbox create should succeed without inferred provider"); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("no-inferred-provider"), + command: &["claude".into(), "--version".into()], + tty_override: Some(true), + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("sandbox create should succeed without inferred provider"); - let requests = create_requests(&server).await; - let providers = requests[0] - .spec - .as_ref() - .expect("sandbox spec should be sent") - .providers - .clone(); - assert!( - providers.is_empty(), - "missing local credentials should skip inferred providers, got {providers:?}" - ); + let requests = create_requests(&server).await; + let providers = requests[0] + .spec + .as_ref() + .expect("sandbox spec should be sent") + .providers + .clone(); + assert!( + providers.is_empty(), + "missing local credentials should skip inferred providers, got {providers:?}" + ); + }) + .await; } #[tokio::test] @@ -2389,39 +2454,43 @@ async fn sandbox_create_returns_vm_error_without_waiting_for_timeout() { .state .vm_error_after_started .store(true, Ordering::SeqCst); - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env_with( + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env_with( &fake_ssh_dir, &xdg_dir, &[("OPENSHELL_PROVISION_TIMEOUT", "1".to_string())], - ); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); - - let started_at = Instant::now(); - let err = run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("vm-error"), - command: &["echo".into(), "OK".into()], - ..test_config() - }, - "default", - &tls, ) - .await - .expect_err("sandbox create should fail on terminal VM error"); + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); - assert!( - started_at.elapsed() < Duration::from_secs(2), - "terminal VM errors should not wait for the provisioning timeout" - ); - let rendered = err.to_string(); - assert!(rendered.contains("sandbox entered error phase while provisioning")); - assert!(rendered.contains("ProcessExited: VM process exited with status 0")); - assert!(!rendered.contains("timed out")); + let started_at = Instant::now(); + let err = run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("vm-error"), + command: &["echo".into(), "OK".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect_err("sandbox create should fail on terminal VM error"); + + assert!( + started_at.elapsed() < Duration::from_secs(2), + "terminal VM errors should not wait for the provisioning timeout" + ); + let rendered = err.to_string(); + assert!(rendered.contains("sandbox entered error phase while provisioning")); + assert!(rendered.contains("ProcessExited: VM process exited with status 0")); + assert!(!rendered.contains("timed out")); + }) + .await; } #[tokio::test] @@ -2437,29 +2506,33 @@ async fn sandbox_create_preserves_vm_error_when_exit_code_is_observed() { .state .vm_error_with_observed_exit .store(true, Ordering::SeqCst); - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); - - let err = run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("vm-error-with-exit"), - command: &["echo".into(), "OK".into()], - ..test_config() - }, - "default", - &tls, - ) - .await - .expect_err("an observed process exit must not hide the infrastructure error"); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + let err = run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("vm-error-with-exit"), + command: &["echo".into(), "OK".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect_err("an observed process exit must not hide the infrastructure error"); - let rendered = err.to_string(); - assert!(rendered.contains("sandbox entered error phase while provisioning")); - assert!(rendered.contains("ProcessExited: VM process exited with status 0")); + let rendered = err.to_string(); + assert!(rendered.contains("sandbox entered error phase while provisioning")); + assert!(rendered.contains("ProcessExited: VM process exited with status 0")); + }) + .await; } #[tokio::test] @@ -2470,45 +2543,48 @@ async fn sandbox_create_accepts_ready_before_create_returns() { .state .ready_before_create_returns .store(true, Ordering::SeqCst); - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env_with( + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + test_env_with( &fake_ssh_dir, &xdg_dir, &[("OPENSHELL_PROVISION_TIMEOUT", "1".to_string())], - ); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); - - let exit_code = tokio::time::timeout( - Duration::from_secs(10), - run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("already-ready"), - command: &["echo".into(), "OK".into()], - ..test_config() - }, - "default", - &tls, - ), ) - .await - .expect("creation must finish while the watch remains open") - .expect("an already-Ready sandbox must not wait for a new provisioning transition"); + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + let exit_code = tokio::time::timeout( + Duration::from_secs(10), + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("already-ready"), + command: &["echo".into(), "OK".into()], + ..test_config() + }, + "default", + &tls, + ), + ) + .await + .expect("creation must finish while the watch remains open") + .expect("an already-Ready sandbox must not wait for a new provisioning transition"); - assert_eq!(exit_code, 0); - assert_eq!(create_requests(&server).await.len(), 1); - assert_eq!( - server - .openshell - .state - .ssh_session_requests - .load(Ordering::SeqCst), - 1, - "the initial Ready snapshot must allow the command to attach" - ); + assert_eq!(exit_code, 0); + assert_eq!(create_requests(&server).await.len(), 1); + assert_eq!( + server + .openshell + .state + .ssh_session_requests + .load(Ordering::SeqCst), + 1, + "the initial Ready snapshot must allow the command to attach" + ); + }) + .await; } #[tokio::test] @@ -2519,29 +2595,33 @@ async fn sandbox_create_keeps_waiting_while_vm_progress_arrives() { .state .vm_slow_progress_before_ready .store(true, Ordering::SeqCst); - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env_with( + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env_with( &fake_ssh_dir, &xdg_dir, &[("OPENSHELL_PROVISION_TIMEOUT", "1".to_string())], - ); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); - - run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("vm-slow-progress"), - command: &["echo".into(), "OK".into()], - ..test_config() - }, - "default", - &tls, ) - .await - .expect("sandbox create should not time out while VM progress is active"); + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("vm-slow-progress"), + command: &["echo".into(), "OK".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("sandbox create should not time out while VM progress is active"); + }) + .await; } #[tokio::test] @@ -2552,36 +2632,40 @@ async fn sandbox_create_times_out_when_only_logs_arrive() { .state .vm_log_churn_before_ready .store(true, Ordering::SeqCst); - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env_with( + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env_with( &fake_ssh_dir, &xdg_dir, &[("OPENSHELL_PROVISION_TIMEOUT", "1".to_string())], - ); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); - - let started_at = Instant::now(); - let err = run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("vm-log-churn"), - command: &["echo".into(), "OK".into()], - ..test_config() - }, - "default", - &tls, ) - .await - .expect_err("sandbox create should time out when only logs arrive"); + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); - assert!( - started_at.elapsed() < Duration::from_secs(2), - "logs should not extend the provisioning timeout" - ); - assert!(err.to_string().contains("sandbox provisioning timed out")); + let started_at = Instant::now(); + let err = run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("vm-log-churn"), + command: &["echo".into(), "OK".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect_err("sandbox create should time out when only logs arrive"); + + assert!( + started_at.elapsed() < Duration::from_secs(2), + "logs should not extend the provisioning timeout" + ); + assert!(err.to_string().contains("sandbox provisioning timed out")); + }) + .await; } #[tokio::test] @@ -2597,35 +2681,39 @@ async fn sandbox_create_retries_terminal_attachment_until_relay_registers() { .state .ssh_session_failures_remaining .store(1, Ordering::SeqCst); - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); - - let exit_code = run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("fast-command"), - command: &["echo".into(), "OK".into()], - ..test_config() - }, - "default", - &tls, - ) - .await - .expect("sandbox create should wait for the declared terminal attachment relay"); - - assert_eq!(exit_code, 0); - assert_eq!( - server - .openshell - .state - .ssh_session_requests - .load(Ordering::SeqCst), - 2 - ); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + let exit_code = run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("fast-command"), + command: &["echo".into(), "OK".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("sandbox create should wait for the declared terminal attachment relay"); + + assert_eq!(exit_code, 0); + assert_eq!( + server + .openshell + .state + .ssh_session_requests + .load(Ordering::SeqCst), + 2 + ); + }) + .await; } #[tokio::test] @@ -2636,32 +2724,36 @@ async fn sandbox_create_waits_for_main_result_after_provisional_container_exit() .state .terminal_after_provisional_container_exit .store(true, Ordering::SeqCst); - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); - - let exit_code = run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("fast-ephemeral-command"), - keep: false, - command: &["echo".into(), "OK".into()], - ..test_config() - }, - "default", - &tls, - ) - .await - .expect("a provisional container exit must yield to the canonical main-process result"); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + let exit_code = run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("fast-ephemeral-command"), + keep: false, + command: &["echo".into(), "OK".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("a provisional container exit must yield to the canonical main-process result"); - assert_eq!(exit_code, 0); - assert_eq!( - deleted_names(&server).await, - vec![vec!["fast-ephemeral-command".to_string()]] - ); + assert_eq!(exit_code, 0); + assert_eq!( + deleted_names(&server).await, + vec![vec!["fast-ephemeral-command".to_string()]] + ); + }) + .await; } #[tokio::test] @@ -2672,9 +2764,10 @@ async fn sandbox_create_bounds_provisional_container_exit_reconciliation() { .state .provisional_container_exit_without_result .store(true, Ordering::SeqCst); - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir).run_async(async { let tls = test_tls(&server); install_fake_ssh(&fake_ssh_dir); @@ -2729,222 +2822,250 @@ async fn sandbox_create_bounds_provisional_container_exit_reconciliation() { "provisional container exit returned before reconciliation: {reconciliation_elapsed:?}" ); assert!(deleted_names(&server).await.is_empty()); + }).await; } #[tokio::test] async fn sandbox_create_deletes_command_sessions_with_no_keep() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); - - run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("ephemeral-command"), - keep: false, - command: &["echo".into(), "OK".into()], - ..test_config() - }, - "default", - &tls, - ) - .await - .expect("sandbox create should succeed"); - - assert_eq!( - deleted_names(&server).await, - vec![vec!["ephemeral-command".to_string()]] - ); - let requests = create_requests(&server).await; - assert_eq!( - requests[0] - .annotations - .get("openshell.nvidia.com/retention") - .map(String::as_str), - Some("ephemeral") - ); - assert_eq!( - load_last_sandbox("openshell", "default"), - None, - "no-keep sandboxes should not be persisted as last-used" - ); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("ephemeral-command"), + keep: false, + command: &["echo".into(), "OK".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("sandbox create should succeed"); + + assert_eq!( + deleted_names(&server).await, + vec![vec!["ephemeral-command".to_string()]] + ); + let requests = create_requests(&server).await; + assert_eq!( + requests[0] + .annotations + .get("openshell.nvidia.com/retention") + .map(String::as_str), + Some("ephemeral") + ); + assert_eq!( + load_last_sandbox("openshell", "default"), + None, + "no-keep sandboxes should not be persisted as last-used" + ); + }) + .await; } #[tokio::test] async fn sandbox_create_returns_exact_main_status_after_no_keep_cleanup() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_executable_script(&fake_ssh_dir, "ssh", "#!/bin/sh\nexit 7\n"); - - let exit_code = run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("ephemeral-failure"), - keep: false, - command: &["sh".into(), "-c".into(), "exit 7".into()], - ..test_config() - }, - "default", - &tls, - ) - .await - .expect("a main-process failure is a command result, not a cleanup error"); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_executable_script(&fake_ssh_dir, "ssh", "#!/bin/sh\nexit 7\n"); + + let exit_code = run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("ephemeral-failure"), + keep: false, + command: &["sh".into(), "-c".into(), "exit 7".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("a main-process failure is a command result, not a cleanup error"); - assert_eq!(exit_code, 7); - assert_eq!( - deleted_names(&server).await, - vec![vec!["ephemeral-failure".to_string()]] - ); + assert_eq!(exit_code, 7); + assert_eq!( + deleted_names(&server).await, + vec![vec!["ephemeral-failure".to_string()]] + ); + }) + .await; } #[tokio::test] async fn sandbox_create_deletes_shell_sessions_with_no_keep() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); - - run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("ephemeral-shell"), - keep: false, - tty_override: Some(true), - ..test_config() - }, - "default", - &tls, - ) - .await - .expect("sandbox create shell should succeed"); - - assert_eq!( - deleted_names(&server).await, - vec![vec!["ephemeral-shell".to_string()]] - ); - assert_eq!( - load_last_sandbox("openshell", "default"), - None, - "no-keep shell sessions should not be persisted as last-used" - ); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("ephemeral-shell"), + keep: false, + tty_override: Some(true), + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("sandbox create shell should succeed"); + + assert_eq!( + deleted_names(&server).await, + vec![vec!["ephemeral-shell".to_string()]] + ); + assert_eq!( + load_last_sandbox("openshell", "default"), + None, + "no-keep shell sessions should not be persisted as last-used" + ); + }) + .await; } #[tokio::test] async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); - - run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("persistent-keep"), - command: &["echo".into(), "OK".into()], - ..test_config() - }, - "default", - &tls, - ) - .await - .expect("sandbox create should succeed"); - - assert!(deleted_names(&server).await.is_empty()); - assert_eq!( - load_last_sandbox("openshell", "default").as_deref(), - Some("persistent-keep"), - "persistent sandboxes should remain selectable as last-used" - ); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("persistent-keep"), + command: &["echo".into(), "OK".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("sandbox create should succeed"); + + assert!(deleted_names(&server).await.is_empty()); + assert_eq!( + load_last_sandbox("openshell", "default").as_deref(), + Some("persistent-keep"), + "persistent sandboxes should remain selectable as last-used" + ); + }) + .await; } #[tokio::test] async fn sandbox_create_keeps_sandbox_with_forwarding() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_fake_forwarding_ssh(&fake_ssh_dir); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let forward_port = listener.local_addr().unwrap().port(); - drop(listener); - - run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("persistent-forward"), - keep: false, - forward: Some(openshell_core::forward::ForwardSpec::new(forward_port)), - command: &["echo".into(), "OK".into()], - ..test_config() - }, - "default", - &tls, - ) - .await - .expect("sandbox create with forward should succeed"); - - assert!(deleted_names(&server).await.is_empty()); - let record = - openshell_core::forward::read_forward_pid("default", "persistent-forward", forward_port) + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_fake_forwarding_ssh(&fake_ssh_dir); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let forward_port = listener.local_addr().unwrap().port(); + drop(listener); + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("persistent-forward"), + keep: false, + forward: Some(openshell_core::forward::ForwardSpec::new(forward_port)), + command: &["echo".into(), "OK".into()], + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("sandbox create with forward should succeed"); + + assert!(deleted_names(&server).await.is_empty()); + let record = openshell_core::forward::read_forward_pid( + "default", + "persistent-forward", + forward_port, + ) .expect("fake forward should be tracked"); - let _ = std::process::Command::new("kill") - .arg(record.pid.to_string()) - .status(); + let _ = std::process::Command::new("kill") + .arg(record.pid.to_string()) + .status(); + }) + .await; } #[tokio::test] async fn sandbox_create_exposes_service_after_ready_and_keeps_sandbox() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - - run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("sandbox"), - keep: false, - expose: Some(4500), - expose_authorization_mode: - openshell_core::proto::ServiceAuthorizationMode::BearerPassthrough, - detach: true, - ..test_config() - }, - "default", - &tls, - ) - .await - .expect("sandbox create with service exposure should succeed"); - - assert!(deleted_names(&server).await.is_empty()); - let create_requests = create_requests(&server).await; - assert_eq!(create_requests.len(), 1); - assert_eq!(create_requests[0].service_exposures.len(), 1); - assert_eq!(create_requests[0].service_exposures[0].service, ""); - assert_eq!(create_requests[0].service_exposures[0].target_port, 4500); - assert_eq!( - create_requests[0].service_exposures[0].authorization_mode(), - openshell_core::proto::ServiceAuthorizationMode::BearerPassthrough - ); - assert!(expose_service_requests(&server).await.is_empty()); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("sandbox"), + keep: false, + expose: Some(4500), + expose_authorization_mode: + openshell_core::proto::ServiceAuthorizationMode::BearerPassthrough, + detach: true, + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("sandbox create with service exposure should succeed"); + + assert!(deleted_names(&server).await.is_empty()); + let create_requests = create_requests(&server).await; + assert_eq!(create_requests.len(), 1); + assert_eq!(create_requests[0].service_exposures.len(), 1); + assert_eq!(create_requests[0].service_exposures[0].service, ""); + assert_eq!(create_requests[0].service_exposures[0].target_port, 4500); + assert_eq!( + create_requests[0].service_exposures[0].authorization_mode(), + openshell_core::proto::ServiceAuthorizationMode::BearerPassthrough + ); + assert!(expose_service_requests(&server).await.is_empty()); + }) + .await; } #[tokio::test] @@ -2975,80 +3096,89 @@ async fn service_expose_forwards_bearer_passthrough_mode() { #[tokio::test] async fn sandbox_forward_background_tracks_owned_child_when_pid_discovery_fails() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_fake_forwarding_ssh(&fake_ssh_dir); - install_fake_pgrep_no_match(&fake_ssh_dir); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let forward_port = listener.local_addr().unwrap().port(); - drop(listener); - - let spec = openshell_core::forward::ForwardSpec::new(forward_port); - run::sandbox_forward( - &server.endpoint, - "owned-forward", - &spec, - true, - &tls, - "default", - ) - .await - .expect("background forward should track the owned SSH child without PID discovery"); - let record = - openshell_core::forward::read_forward_pid("default", "owned-forward", forward_port) - .expect("owned background forward should write a PID file"); - - assert!( - openshell_core::forward::stop_forward("default", "owned-forward", forward_port) - .expect("tracked fake forward should stop"), - "tracked fake forward should be recognized as alive and stopped", - ); - assert!( - wait_for_process_exit(record.pid, Duration::from_secs(2)).await, - "tracked fake forward process should exit after stop" - ); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_fake_forwarding_ssh(&fake_ssh_dir); + install_fake_pgrep_no_match(&fake_ssh_dir); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let forward_port = listener.local_addr().unwrap().port(); + drop(listener); + + let spec = openshell_core::forward::ForwardSpec::new(forward_port); + run::sandbox_forward( + &server.endpoint, + "owned-forward", + &spec, + true, + &tls, + "default", + ) + .await + .expect("background forward should track the owned SSH child without PID discovery"); + let record = + openshell_core::forward::read_forward_pid("default", "owned-forward", forward_port) + .expect("owned background forward should write a PID file"); + + assert!( + openshell_core::forward::stop_forward("default", "owned-forward", forward_port) + .expect("tracked fake forward should stop"), + "tracked fake forward should be recognized as alive and stopped", + ); + assert!( + wait_for_process_exit(record.pid, Duration::from_secs(2)).await, + "tracked fake forward process should exit after stop" + ); + }) + .await; } #[tokio::test] #[ignore = "flaky under concurrent test execution"] async fn sandbox_forward_foreground_fails_when_ssh_exits_before_listener_opens() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let forward_port = listener.local_addr().unwrap().port(); - drop(listener); - - let spec = openshell_core::forward::ForwardSpec::new(forward_port); - let err = run::sandbox_forward( - &server.endpoint, - "foreground-forward", - &spec, - false, - &tls, - "default", - ) - .await - .expect_err("foreground forward should fail when ssh exits before listener readiness"); - let msg = format!("{err}"); - assert!( - msg.contains("ssh exited before local forward listener opened"), - "error should explain that ssh exited before listener readiness, got: {msg}", - ); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let forward_port = listener.local_addr().unwrap().port(); + drop(listener); + + let spec = openshell_core::forward::ForwardSpec::new(forward_port); + let err = run::sandbox_forward( + &server.endpoint, + "foreground-forward", + &spec, + false, + &tls, + "default", + ) + .await + .expect_err("foreground forward should fail when ssh exits before listener readiness"); + let msg = format!("{err}"); + assert!( + msg.contains("ssh exited before local forward listener opened"), + "error should explain that ssh exited before listener readiness, got: {msg}", + ); + }) + .await; } #[tokio::test] #[ignore = "flaky under concurrent test execution"] async fn sandbox_forward_background_terminates_owned_child_when_listener_never_opens() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir).run_async(async { let tls = test_tls(&server); let fake_forward = install_fake_unreachable_forwarding_ssh(&fake_ssh_dir); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -3100,47 +3230,52 @@ async fn sandbox_forward_background_terminates_owned_child_when_listener_never_o log.trim(), ); } + }).await; } #[tokio::test] async fn sandbox_create_sends_environment_variables() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - let tls = test_tls(&server); - install_fake_ssh(&fake_ssh_dir); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + let tls = test_tls(&server); + install_fake_ssh(&fake_ssh_dir); + + run::sandbox_create( + &server.endpoint, + "openshell", + run::SandboxCreateConfig { + name: Some("env-test"), + command: &["echo".into(), "OK".into()], + environment: HashMap::from([ + ("FOO".into(), "bar".into()), + ("BAZ".into(), "qux=with=equals".into()), + ]), + ..test_config() + }, + "default", + &tls, + ) + .await + .expect("sandbox create should succeed"); - run::sandbox_create( - &server.endpoint, - "openshell", - run::SandboxCreateConfig { - name: Some("env-test"), - command: &["echo".into(), "OK".into()], - environment: HashMap::from([ - ("FOO".into(), "bar".into()), - ("BAZ".into(), "qux=with=equals".into()), - ]), - ..test_config() - }, - "default", - &tls, - ) - .await - .expect("sandbox create should succeed"); - - let requests = create_requests(&server).await; - let environment = &requests[0] - .spec - .as_ref() - .expect("spec should be present") - .environment; - assert_eq!(environment.get("FOO").map(String::as_str), Some("bar")); - assert_eq!( - environment.get("BAZ").map(String::as_str), - Some("qux=with=equals") - ); - assert_eq!(environment.len(), 2); + let requests = create_requests(&server).await; + let environment = &requests[0] + .spec + .as_ref() + .expect("spec should be present") + .environment; + assert_eq!(environment.get("FOO").map(String::as_str), Some("bar")); + assert_eq!( + environment.get("BAZ").map(String::as_str), + Some("qux=with=equals") + ); + assert_eq!(environment.len(), 2); + }) + .await; } #[tokio::test] @@ -3229,7 +3364,7 @@ async fn run_cli_sandbox_create( name: &str, extra_args: &[&str], ) -> std::process::Output { - let xdg_dir = tempfile::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); prepare_cli_xdg(server, &xdg_dir); run_cli_sandbox_create_with_xdg(server, &xdg_dir, name, extra_args).await } @@ -3314,40 +3449,43 @@ async fn sandbox_create_upload_is_rejected_before_provisioning_when_planning_fai #[tokio::test] async fn sandbox_create_upload_warns_and_reaches_ssh_outside_git_repository() { let server = run_server().await; - let fake_ssh_dir = tempfile::tempdir().unwrap(); - let xdg_dir = tempfile::tempdir().unwrap(); - let _env = test_env(&fake_ssh_dir, &xdg_dir); - install_executable_script(&fake_ssh_dir, "ssh", "#!/bin/sh\nexit 7\n"); - let source = tempfile::tempdir().unwrap(); - fs::write(source.path().join("marker.txt"), "dummy content").unwrap(); - let args = ["--detach", "--upload", source.path().to_str().unwrap()]; - let result = run_cli_sandbox_create(&server, "upload-non-repository", &args).await; - let stderr = String::from_utf8_lossy(&result.stderr); - // The fake SSH transport fails; preflight must still let it try. - assert!(!result.status.success(), "{stderr}"); - assert!(stderr.contains("outside a Git work tree"), "{stderr}"); - assert!( - stderr.contains(".gitignore rules are not applied"), - "{stderr}" - ); - assert!(!stderr.contains("Git filtering failed"), "{stderr}"); - // A transfer failure after provisioning keeps the sandbox and says so. - assert!( - stderr.contains("Sandbox 'upload-non-repository' was created and still exists"), - "{stderr}" - ); - assert!(stderr.contains("openshell sandbox upload"), "{stderr}"); - assert!(stderr.contains("openshell sandbox delete"), "{stderr}"); - assert_eq!(create_requests(&server).await.len(), 1); - assert!( - server - .openshell - .state - .ssh_session_requests - .load(Ordering::SeqCst) - > 0, - "an upload outside a repository must reach the SSH transport", - ); + let fake_ssh_dir = helpers::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); + test_env(&fake_ssh_dir, &xdg_dir) + .run_async(async { + install_executable_script(&fake_ssh_dir, "ssh", "#!/bin/sh\nexit 7\n"); + let source = tempfile::tempdir().unwrap(); + fs::write(source.path().join("marker.txt"), "dummy content").unwrap(); + let args = ["--detach", "--upload", source.path().to_str().unwrap()]; + let result = run_cli_sandbox_create(&server, "upload-non-repository", &args).await; + let stderr = String::from_utf8_lossy(&result.stderr); + // The fake SSH transport fails; preflight must still let it try. + assert!(!result.status.success(), "{stderr}"); + assert!(stderr.contains("outside a Git work tree"), "{stderr}"); + assert!( + stderr.contains(".gitignore rules are not applied"), + "{stderr}" + ); + assert!(!stderr.contains("Git filtering failed"), "{stderr}"); + // A transfer failure after provisioning keeps the sandbox and says so. + assert!( + stderr.contains("Sandbox 'upload-non-repository' was created and still exists"), + "{stderr}" + ); + assert!(stderr.contains("openshell sandbox upload"), "{stderr}"); + assert!(stderr.contains("openshell sandbox delete"), "{stderr}"); + assert_eq!(create_requests(&server).await.len(), 1); + assert!( + server + .openshell + .state + .ssh_session_requests + .load(Ordering::SeqCst) + > 0, + "an upload outside a repository must reach the SSH transport", + ); + }) + .await; } async fn run_cli_sandbox_template_create( @@ -3355,7 +3493,7 @@ async fn run_cli_sandbox_template_create( name: &str, extra_args: &[&str], ) -> std::process::Output { - let xdg_dir = tempfile::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); let tls_dir = xdg_dir.path().join("openshell/gateways/openshell/mtls"); fs::create_dir_all(&tls_dir).unwrap(); for filename in ["ca.crt", "tls.crt", "tls.key"] { @@ -3481,7 +3619,7 @@ fn write_oidc_test_credentials( async fn sandbox_create_fails_before_mutation_when_expired_oidc_refresh_is_rejected() { let server = run_server().await; let issuer = run_rejected_oidc_refresh_server().await; - let xdg_dir = tempfile::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); prepare_cli_xdg(&server, &xdg_dir); write_oidc_test_credentials(&server, &xdg_dir, &issuer, 0); @@ -3517,7 +3655,7 @@ async fn sandbox_create_fails_before_mutation_when_expired_oidc_refresh_is_rejec async fn sandbox_create_continues_with_unexpired_cached_token_when_refresh_fails() { let server = run_server().await; let issuer = run_rejected_oidc_refresh_server().await; - let xdg_dir = tempfile::tempdir().unwrap(); + let xdg_dir = helpers::tempdir().unwrap(); prepare_cli_xdg(&server, &xdg_dir); let expires_at = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index fb8d39895f..94a185567d 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -3,7 +3,8 @@ mod helpers; -use helpers::{EnvVarGuard, build_ca, build_client_cert, build_server_cert}; +use helpers::TempDir; +use helpers::{Environment, build_ca, build_client_cert, build_server_cert}; use openshell_bootstrap::{load_last_sandbox, save_last_sandbox}; use openshell_cli::run; use openshell_cli::tls::TlsOptions; @@ -25,7 +26,6 @@ use openshell_core::proto::{ ServiceStatus, SupervisorMessage, UpdateProviderRequest, WatchSandboxRequest, }; use std::sync::Arc; -use tempfile::TempDir; use tokio::net::TcpListener; use tokio::sync::{Mutex, mpsc}; use tokio_stream::wrappers::TcpListenerStream; @@ -787,7 +787,7 @@ async fn run_server() -> TestServer { .unwrap(); }); - let dir = tempfile::tempdir().unwrap(); + let dir = helpers::tempdir().unwrap(); let ca_path = dir.path().join("ca.crt"); let cert_path = dir.path().join("tls.crt"); let key_path = dir.path().join("tls.key"); @@ -858,29 +858,32 @@ async fn sandbox_get_policy_only_round_trip() { #[tokio::test] async fn sandbox_get_with_persisted_last_sandbox() { let ts = run_server().await; - let xdg_dir = tempfile::tempdir().unwrap(); - let _guard = EnvVarGuard::set(&[("XDG_CONFIG_HOME", xdg_dir.path().to_str().unwrap())]); - - // Persist a last-used sandbox for "integration-cluster". - save_last_sandbox("integration-cluster", "default", "persisted-sb") - .expect("save_last_sandbox should succeed"); - - // Resolve the name (simulates what the CLI does in main.rs). - let resolved = load_last_sandbox("integration-cluster", "default") - .expect("load_last_sandbox should return the saved name"); - assert_eq!(resolved, "persisted-sb"); - - // Call sandbox_get with the resolved name. - run::sandbox_get(&ts.endpoint, &resolved, false, "table", "default", &ts.tls) - .await - .expect("sandbox_get should succeed"); - - let recorded = ts.openshell.state.last_get_name.lock().await.clone(); - assert_eq!( - recorded.as_deref(), - Some("persisted-sb"), - "the persisted sandbox name should flow through to the gRPC request" - ); + let xdg_dir = helpers::tempdir().unwrap(); + + Environment::from_pairs(&[("XDG_CONFIG_HOME", xdg_dir.path().to_str().unwrap())]) + .run_async(async { + // Persist a last-used sandbox for "integration-cluster". + save_last_sandbox("integration-cluster", "default", "persisted-sb") + .expect("save_last_sandbox should succeed"); + + // Resolve the name (simulates what the CLI does in main.rs). + let resolved = load_last_sandbox("integration-cluster", "default") + .expect("load_last_sandbox should return the saved name"); + assert_eq!(resolved, "persisted-sb"); + + // Call sandbox_get with the resolved name. + run::sandbox_get(&ts.endpoint, &resolved, false, "table", "default", &ts.tls) + .await + .expect("sandbox_get should succeed"); + + let recorded = ts.openshell.state.last_get_name.lock().await.clone(); + assert_eq!( + recorded.as_deref(), + Some("persisted-sb"), + "the persisted sandbox name should flow through to the gRPC request" + ); + }) + .await; } #[tokio::test] @@ -1018,27 +1021,30 @@ async fn policy_get_explicit_revision_uses_stored_policy_status() { #[tokio::test] async fn explicit_name_takes_precedence_over_persisted() { let ts = run_server().await; - let xdg_dir = tempfile::tempdir().unwrap(); - let _guard = EnvVarGuard::set(&[("XDG_CONFIG_HOME", xdg_dir.path().to_str().unwrap())]); - - // Persist one name, but supply a different one explicitly. - save_last_sandbox("my-cluster", "default", "old-sandbox").expect("save should succeed"); - - run::sandbox_get( - &ts.endpoint, - "explicit-sandbox", - false, - "table", - "default", - &ts.tls, - ) - .await - .expect("sandbox_get should succeed"); - - let recorded = ts.openshell.state.last_get_name.lock().await.clone(); - assert_eq!( - recorded.as_deref(), - Some("explicit-sandbox"), - "explicit name should be used, not the persisted one" - ); + let xdg_dir = helpers::tempdir().unwrap(); + + Environment::from_pairs(&[("XDG_CONFIG_HOME", xdg_dir.path().to_str().unwrap())]) + .run_async(async { + // Persist one name, but supply a different one explicitly. + save_last_sandbox("my-cluster", "default", "old-sandbox").expect("save should succeed"); + + run::sandbox_get( + &ts.endpoint, + "explicit-sandbox", + false, + "table", + "default", + &ts.tls, + ) + .await + .expect("sandbox_get should succeed"); + + let recorded = ts.openshell.state.last_get_name.lock().await.clone(); + assert_eq!( + recorded.as_deref(), + Some("explicit-sandbox"), + "explicit name should be used, not the persisted one" + ); + }) + .await; } diff --git a/crates/openshell-core/build.rs b/crates/openshell-core/build.rs index 38c961b1d4..d3b078941a 100644 --- a/crates/openshell-core/build.rs +++ b/crates/openshell-core/build.rs @@ -9,6 +9,19 @@ mod build_version; const PROTO_REL: &str = "../../proto"; fn main() -> Result<(), Box> { + // Preserve the vendored include-tree guarantee even when the caller has + // PROTOC_INCLUDE set: prost validates that ambient path independently of + // explicit includes. Re-enter with a child-only environment override. + if env::var_os("PROTOC_INCLUDE").is_some() { + let status = std::process::Command::new(env::current_exe()?) + .env_remove("PROTOC_INCLUDE") + .status()?; + if !status.success() { + return Err(format!("vendored protobuf build failed: {status}").into()); + } + return Ok(()); + } + // --- Git-derived version --- // Compute a version from tags and commit metadata for local builds. In // Docker/CI builds where .git is absent, this silently does nothing and @@ -26,16 +39,11 @@ fn main() -> Result<(), Box> { // --- Protobuf compilation --- // Re-run when anything under proto/ changes (including newly added .proto files). println!("cargo:rerun-if-changed={PROTO_REL}"); - // Use a vendored protoc binary and include tree. System protoc installs - // often omit the well-known type includes (google/protobuf/struct.proto, - // etc.), and protobuf-src requires autotools/sh which breaks MSVC builds. - // SAFETY: This is run at build time in a single-threaded build script context. - // No other threads are reading environment variables concurrently. - #[allow(unsafe_code)] - unsafe { - env::set_var("PROTOC", protoc_bin_vendored::protoc_bin_path()?); - env::set_var("PROTOC_INCLUDE", protoc_bin_vendored::include_path()?); - } + // Configure the vendored compiler and well-known includes without changing + // the build process environment. + let mut proto_config = tonic_prost_build::Config::new(); + proto_config.protoc_executable(protoc_bin_vendored::protoc_bin_path()?); + let proto_include = protoc_bin_vendored::include_path()?; let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?); let proto_root = manifest_dir.join(PROTO_REL); @@ -54,7 +62,7 @@ fn main() -> Result<(), Box> { // Emit a binary FileDescriptorSet so the server can enumerate every // RPC at runtime (used by the per-handler auth exhaustiveness test). .file_descriptor_set_path(&descriptor_path) - .compile_protos(&proto_files, &[proto_root])?; + .compile_with_config(proto_config, &proto_files, &[proto_root, proto_include])?; println!( "cargo:rustc-env=OPENSHELL_DESCRIPTOR_PATH={}", diff --git a/crates/openshell-core/src/forward.rs b/crates/openshell-core/src/forward.rs index b96c52d911..2a7d6e89d5 100644 --- a/crates/openshell-core/src/forward.rs +++ b/crates/openshell-core/src/forward.rs @@ -1072,37 +1072,7 @@ pub fn build_sandbox_notes( #[cfg(test)] mod tests { use super::*; - use std::sync::Mutex; - - static ENV_LOCK: Mutex<()> = Mutex::new(()); - - struct EnvVarGuard { - key: &'static str, - previous: Option, - } - - impl EnvVarGuard { - #[allow(unsafe_code)] // Tests serialize process-wide environment changes with ENV_LOCK. - fn set_path(key: &'static str, value: &std::path::Path) -> Self { - let previous = std::env::var_os(key); - unsafe { - std::env::set_var(key, value); - } - Self { key, previous } - } - } - - #[allow(unsafe_code)] // Tests serialize process-wide environment changes with ENV_LOCK. - impl Drop for EnvVarGuard { - fn drop(&mut self) { - unsafe { - match &self.previous { - Some(value) => std::env::set_var(self.key, value), - None => std::env::remove_var(self.key), - } - } - } - } + use crate::test_environment::Environment; #[test] fn resolve_ssh_gateway_keeps_non_loopback() { @@ -1903,16 +1873,17 @@ mod tests { #[test] fn stop_forward_removes_legacy_pid_file_without_signaling() { - let _lock = ENV_LOCK.lock().unwrap(); let config_dir = tempfile::tempdir().unwrap(); - let _xdg_config = EnvVarGuard::set_path("XDG_CONFIG_HOME", config_dir.path()); - - let pid_path = forward_pid_path("default", "sbx-1", 80).unwrap(); - std::fs::create_dir_all(pid_path.parent().unwrap()).unwrap(); - std::fs::write(&pid_path, "12345").unwrap(); - - assert!(!stop_forward("default", "sbx-1", 80).unwrap()); - assert!(!pid_path.exists()); + Environment::new() + .set("XDG_CONFIG_HOME", config_dir.path()) + .run(|| { + let pid_path = forward_pid_path("default", "sbx-1", 80).unwrap(); + std::fs::create_dir_all(pid_path.parent().unwrap()).unwrap(); + std::fs::write(&pid_path, "12345").unwrap(); + + assert!(!stop_forward("default", "sbx-1", 80).unwrap()); + assert!(!pid_path.exists()); + }); } #[test] @@ -1927,33 +1898,35 @@ mod tests { #[test] fn list_forwards_marks_legacy_pid_records_not_alive() { - let _lock = ENV_LOCK.lock().unwrap(); let config_dir = tempfile::tempdir().unwrap(); - let _xdg_config = EnvVarGuard::set_path("XDG_CONFIG_HOME", config_dir.path()); - - let pid_path = forward_pid_path("default", "sbx-1", 80).unwrap(); - std::fs::create_dir_all(pid_path.parent().unwrap()).unwrap(); - std::fs::write(&pid_path, "12345").unwrap(); - - let forwards = list_forwards().unwrap(); - assert_eq!(forwards.len(), 1); - assert_eq!(forwards[0].workspace, "default"); - assert_eq!(forwards[0].sandbox_name, "sbx-1"); - assert_eq!(forwards[0].port, 80); - assert!(!forwards[0].validated_alive); + Environment::new() + .set("XDG_CONFIG_HOME", config_dir.path()) + .run(|| { + let pid_path = forward_pid_path("default", "sbx-1", 80).unwrap(); + std::fs::create_dir_all(pid_path.parent().unwrap()).unwrap(); + std::fs::write(&pid_path, "12345").unwrap(); + + let forwards = list_forwards().unwrap(); + assert_eq!(forwards.len(), 1); + assert_eq!(forwards[0].workspace, "default"); + assert_eq!(forwards[0].sandbox_name, "sbx-1"); + assert_eq!(forwards[0].port, 80); + assert!(!forwards[0].validated_alive); + }); } #[test] fn find_forward_by_port_ignores_legacy_pid_records() { - let _lock = ENV_LOCK.lock().unwrap(); let config_dir = tempfile::tempdir().unwrap(); - let _xdg_config = EnvVarGuard::set_path("XDG_CONFIG_HOME", config_dir.path()); - - let pid_path = forward_pid_path("default", "old", 80).unwrap(); - std::fs::create_dir_all(pid_path.parent().unwrap()).unwrap(); - std::fs::write(&pid_path, std::process::id().to_string()).unwrap(); - - assert_eq!(find_forward_by_port(80).unwrap(), None); + Environment::new() + .set("XDG_CONFIG_HOME", config_dir.path()) + .run(|| { + let pid_path = forward_pid_path("default", "old", 80).unwrap(); + std::fs::create_dir_all(pid_path.parent().unwrap()).unwrap(); + std::fs::write(&pid_path, std::process::id().to_string()).unwrap(); + + assert_eq!(find_forward_by_port(80).unwrap(), None); + }); } #[test] diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 84d4f8ccf5..d07f9241a4 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -98,3 +98,7 @@ mod build_version; /// Used by tests in `openshell-server` to enumerate every RPC and verify that /// each one has an `#[rpc_auth(...)]` declaration on its handler. pub const FILE_DESCRIPTOR_SET: &[u8] = include_bytes!(env!("OPENSHELL_DESCRIPTOR_PATH")); + +#[cfg(test)] +#[path = "../../../tests/support/environment.rs"] +mod test_environment; diff --git a/crates/openshell-driver-vm/Cargo.toml b/crates/openshell-driver-vm/Cargo.toml index ad676f8302..f8ed6c70e9 100644 --- a/crates/openshell-driver-vm/Cargo.toml +++ b/crates/openshell-driver-vm/Cargo.toml @@ -42,7 +42,7 @@ prost = { workspace = true, optional = true } prost-types = { workspace = true, optional = true } futures = { workspace = true, optional = true } tokio-stream = { workspace = true, features = ["net"], optional = true } -nix = { workspace = true, optional = true } +nix = { workspace = true, optional = true, features = ["resource"] } clap = { workspace = true, optional = true } tracing = { workspace = true, optional = true } tracing-subscriber = { workspace = true, optional = true } @@ -56,7 +56,7 @@ serde = { workspace = true } serde_json = { workspace = true } oci-client = { version = "0.16", optional = true } libc = "0.2" -rustix = { workspace = true, features = ["fs"], optional = true } +rustix = { workspace = true, features = ["fs"] } libloading = { version = "0.8", optional = true } tar = { version = "0.4", optional = true } flate2 = { version = "1", optional = true } @@ -64,6 +64,8 @@ sha2 = { version = "0.10", optional = true } zstd = { version = "0.13", optional = true } [features] +# Portable test instrumentation, independent of the compute runtime. +allocation-tracking = [] default = ["compute-driver", "telemetry"] ## Build the standalone compute driver and its host runtime implementation. compute-driver = [ @@ -87,7 +89,6 @@ compute-driver = [ "dep:prost", "dep:prost-types", "dep:rand", - "dep:rustix", "dep:sha2", "dep:tar", "dep:tokio-stream", diff --git a/crates/openshell-driver-vm/src/allocation_tracking.rs b/crates/openshell-driver-vm/src/allocation_tracking.rs new file mode 100644 index 0000000000..b08ca7e1bb --- /dev/null +++ b/crates/openshell-driver-vm/src/allocation_tracking.rs @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Allocation instrumentation used by network performance tests. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// System allocator with counters for explicit performance tests. +pub struct CountingAllocator; + +static ALLOCATIONS: AtomicU64 = AtomicU64::new(0); +static ALLOCATED_BYTES: AtomicU64 = AtomicU64::new(0); + +#[allow(unsafe_code)] +unsafe impl GlobalAlloc for CountingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let pointer = unsafe { System.alloc(layout) }; + if !pointer.is_null() { + ALLOCATIONS.fetch_add(1, Ordering::Relaxed); + ALLOCATED_BYTES.fetch_add(layout.size() as u64, Ordering::Relaxed); + } + pointer + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + let pointer = unsafe { System.alloc_zeroed(layout) }; + if !pointer.is_null() { + ALLOCATIONS.fetch_add(1, Ordering::Relaxed); + ALLOCATED_BYTES.fetch_add(layout.size() as u64, Ordering::Relaxed); + } + pointer + } + + unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) { + unsafe { System.dealloc(pointer, layout) }; + } + + unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + let pointer = unsafe { System.realloc(pointer, layout, new_size) }; + if !pointer.is_null() { + ALLOCATIONS.fetch_add(1, Ordering::Relaxed); + ALLOCATED_BYTES.fetch_add(new_size as u64, Ordering::Relaxed); + } + pointer + } +} + +pub fn reset() { + ALLOCATIONS.store(0, Ordering::SeqCst); + ALLOCATED_BYTES.store(0, Ordering::SeqCst); +} + +pub fn snapshot() -> (u64, u64) { + ( + ALLOCATIONS.load(Ordering::SeqCst), + ALLOCATED_BYTES.load(Ordering::SeqCst), + ) +} diff --git a/crates/openshell-driver-vm/src/bin/openshell-vm-init.rs b/crates/openshell-driver-vm/src/bin/openshell-vm-init.rs index a8eb52ce1b..649c4ccf01 100644 --- a/crates/openshell-driver-vm/src/bin/openshell-vm-init.rs +++ b/crates/openshell-driver-vm/src/bin/openshell-vm-init.rs @@ -76,7 +76,7 @@ fn parse_command(args: impl IntoIterator) -> Result Result<(), InitError> { // The helper is only invoked by trusted VM guest init, before it hands the // workload to the capability-free sandbox identity. - if unsafe { libc::geteuid() } != 0 { + if rustix::process::geteuid().as_raw() != 0 { return Err(InitError( "prepare-network must run as the VM guest root user".to_string(), )); diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index ab1aca902b..2ec79e7f9e 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -1010,19 +1010,12 @@ impl VmDriver { })?; } #[cfg(unix)] - let liveness_read_fd = liveness_read.as_raw_fd(); - #[cfg(unix)] command - .arg("--parent-liveness-fd") - .arg(liveness_read_fd.to_string()); + .stdin(Stdio::from(liveness_read)) + .arg("--parent-liveness-stdin"); #[cfg(unix)] unsafe { command.pre_exec(move || { - nix::fcntl::fcntl( - liveness_read_fd, - nix::fcntl::FcntlArg::F_SETFD(nix::fcntl::FdFlag::empty()), - ) - .map_err(std::io::Error::other)?; #[cfg(target_os = "linux")] nix::sys::prctl::set_pdeathsig(Signal::SIGKILL).map_err(std::io::Error::other)?; Ok(()) @@ -1036,7 +1029,6 @@ impl VmDriver { })?; #[cfg(unix)] { - drop(liveness_read); Ok((child, Some(fs::File::from(liveness_write)))) } #[cfg(not(unix))] diff --git a/crates/openshell-driver-vm/src/lib.rs b/crates/openshell-driver-vm/src/lib.rs index 5464dd6449..22f45c96e9 100644 --- a/crates/openshell-driver-vm/src/lib.rs +++ b/crates/openshell-driver-vm/src/lib.rs @@ -11,6 +11,9 @@ compile_error!( build a telemetry-free VM driver with `--no-default-features --features defaults-without-telemetry`" ); +#[cfg(feature = "allocation-tracking")] +pub mod allocation_tracking; + #[cfg(feature = "compute-driver")] pub mod driver; #[cfg(feature = "compute-driver")] diff --git a/crates/openshell-driver-vm/src/runtime.rs b/crates/openshell-driver-vm/src/runtime.rs index 4d0680bffe..5556a44448 100644 --- a/crates/openshell-driver-vm/src/runtime.rs +++ b/crates/openshell-driver-vm/src/runtime.rs @@ -464,15 +464,14 @@ fn configure_runtime_loader_env(_runtime_dir: &Path) -> Result<(), String> { fn raise_nofile_limit() { #[cfg(unix)] - unsafe { - let mut rlim = libc::rlimit { - rlim_cur: 0, - rlim_max: 0, - }; - if libc::getrlimit(libc::RLIMIT_NOFILE, &raw mut rlim) == 0 { - rlim.rlim_cur = rlim.rlim_max; - let _ = libc::setrlimit(libc::RLIMIT_NOFILE, &raw const rlim); - } + if let Ok((_, maximum)) = + nix::sys::resource::getrlimit(nix::sys::resource::Resource::RLIMIT_NOFILE) + { + let _ = nix::sys::resource::setrlimit( + nix::sys::resource::Resource::RLIMIT_NOFILE, + maximum, + maximum, + ); } } diff --git a/crates/openshell-isolation-interface/Cargo.toml b/crates/openshell-isolation-interface/Cargo.toml index fe3e6f9551..642c927cef 100644 --- a/crates/openshell-isolation-interface/Cargo.toml +++ b/crates/openshell-isolation-interface/Cargo.toml @@ -18,13 +18,6 @@ serde_json = { workspace = true } sha2 = { workspace = true } tokio = { workspace = true } -[target.'cfg(unix)'.dependencies] -libc = "0.2" - -[target.'cfg(target_os = "linux")'.dependencies] -rustix = { workspace = true, features = ["fs", "net", "process"] } -socket2 = { workspace = true, features = ["all"] } - [dev-dependencies] tokio = { workspace = true } diff --git a/crates/openshell-isolation-interface/README.md b/crates/openshell-isolation-interface/README.md new file mode 100644 index 0000000000..2329508ec0 --- /dev/null +++ b/crates/openshell-isolation-interface/README.md @@ -0,0 +1,8 @@ +# Isolation backend interface + +This crate defines the supervisor-facing `IsolationBackend` contract, lifecycle +states, and shared types. It forbids unsafe code, including in its tests. + +Linux enforcement mechanisms belong to `openshell-sandbox::linux`. Backends +implement this crate's safe contract without importing Linux mechanisms through +the interface crate. diff --git a/crates/openshell-isolation-interface/src/lib.rs b/crates/openshell-isolation-interface/src/lib.rs index b09320dc97..bb29678bcc 100644 --- a/crates/openshell-isolation-interface/src/lib.rs +++ b/crates/openshell-isolation-interface/src/lib.rs @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +#![forbid(unsafe_code)] + //! The `OpenShell` **Isolation Backend** runtime contract (RFC 0012). //! //! An isolation backend establishes and enforces a workload's isolation boundary; @@ -49,7 +51,3 @@ pub struct AgentSpec { } pub mod contract; - -/// Linux-only primitives shared by capability-free sandbox implementations. -#[cfg(target_os = "linux")] -pub mod linux; diff --git a/crates/openshell-sandbox-backend/build.rs b/crates/openshell-sandbox-backend/build.rs index 8be206f42a..bdffd02b11 100644 --- a/crates/openshell-sandbox-backend/build.rs +++ b/crates/openshell-sandbox-backend/build.rs @@ -1,22 +1,35 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -#![allow(unsafe_code)] - -use std::env; - fn main() -> Result<(), Box> { + // Preserve the vendored include-tree guarantee even when the caller has + // PROTOC_INCLUDE set: prost validates that ambient path independently of + // explicit includes. Re-enter with a child-only environment override. + if std::env::var_os("PROTOC_INCLUDE").is_some() { + let status = std::process::Command::new(std::env::current_exe()?) + .env_remove("PROTOC_INCLUDE") + .status()?; + if !status.success() { + return Err(format!("vendored protobuf build failed: {status}").into()); + } + return Ok(()); + } + println!("cargo:rerun-if-changed=proto/openshell_sandbox.proto"); - // SAFETY: Cargo build scripts run this setup before starting code generation. - unsafe { - env::set_var("PROTOC", protoc_bin_vendored::protoc_bin_path()?); - env::set_var("PROTOC_INCLUDE", protoc_bin_vendored::include_path()?); - } + // Configure the vendored compiler and well-known includes without changing + // the build process environment. + let mut proto_config = tonic_prost_build::Config::new(); + proto_config.protoc_executable(protoc_bin_vendored::protoc_bin_path()?); + let proto_include = protoc_bin_vendored::include_path()?; tonic_prost_build::configure() .build_server(true) .build_client(true) - .compile_protos(&["proto/openshell_sandbox.proto"], &["proto"])?; + .compile_with_config( + proto_config, + &[std::path::PathBuf::from("proto/openshell_sandbox.proto")], + &[std::path::PathBuf::from("proto"), proto_include], + )?; Ok(()) } diff --git a/crates/openshell-sandbox-backend/src/lib.rs b/crates/openshell-sandbox-backend/src/lib.rs index 87b25dccd3..13ee0fad34 100644 --- a/crates/openshell-sandbox-backend/src/lib.rs +++ b/crates/openshell-sandbox-backend/src/lib.rs @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +#![forbid(unsafe_code)] + //! `OpenShell` Sandbox Protocol and its concrete RFC 0012 backend. //! //! [`OpenShellRuntimeBackend`] is the supervisor-side implementation of the diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index 55b84ca431..77dab9368b 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -3,14 +3,8 @@ //! Host-side RFC 0012 backend for an already-provisioned remote boundary. -#![allow(unsafe_code)] - #[cfg(test)] use std::collections::HashMap; -#[cfg(target_os = "linux")] -use std::mem::size_of; -#[cfg(target_os = "linux")] -use std::os::fd::{FromRawFd as _, IntoRawFd as _}; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -2094,42 +2088,16 @@ fn connect_host_vsock( guest_cid: u32, control_port: u32, ) -> Result { - let fd = unsafe { libc::socket(libc::AF_VSOCK, libc::SOCK_STREAM | libc::SOCK_CLOEXEC, 0) }; - if fd < 0 { - return Err(BackendError::Unavailable(format!( - "create host vsock: {}", - std::io::Error::last_os_error() - ))); - } - let fd = unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) }; - let family = libc::sa_family_t::try_from(libc::AF_VSOCK).map_err(|error| { - BackendError::Unavailable(format!("convert host vsock address family: {error}")) - })?; - let address = libc::sockaddr_vm { - svm_family: family, - svm_reserved1: 0, - svm_port: control_port, - svm_cid: guest_cid, - svm_zero: [0; 4], - }; - let address_length = - libc::socklen_t::try_from(size_of::()).map_err(|error| { - BackendError::Unavailable(format!("convert host vsock address length: {error}")) + let socket = socket2::Socket::new(socket2::Domain::VSOCK, socket2::Type::STREAM, None) + .map_err(|error| BackendError::Unavailable(format!("create host vsock: {error}")))?; + socket + .connect(&socket2::SockAddr::vsock(guest_cid, control_port)) + .map_err(|error| { + BackendError::Unavailable(format!( + "connect host vsock CID {guest_cid} port {control_port}: {error}" + )) })?; - let result = unsafe { - libc::connect( - std::os::fd::AsRawFd::as_raw_fd(&fd), - (&raw const address).cast::(), - address_length, - ) - }; - if result != 0 { - return Err(BackendError::Unavailable(format!( - "connect host vsock CID {guest_cid} port {control_port}: {}", - std::io::Error::last_os_error() - ))); - } - let stream = unsafe { std::os::unix::net::UnixStream::from_raw_fd(fd.into_raw_fd()) }; + let stream = std::os::unix::net::UnixStream::from(socket); stream.set_nonblocking(true).map_err(|error| { BackendError::Unavailable(format!("set host vsock nonblocking: {error}")) })?; diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 50d91f0abf..88bce43fab 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -50,7 +50,7 @@ clap = { workspace = true } miette = { workspace = true } # Unix identity and bootstrap ownership -nix = { workspace = true } +nix = { workspace = true, features = ["resource", "socket", "feature", "poll"] } # TLS crypto provider install (main.rs) rustls = { workspace = true } @@ -68,14 +68,14 @@ tracing-subscriber = { workspace = true } [target.'cfg(unix)'.dependencies] libc = "0.2" -rustix = { workspace = true } +rustix = { workspace = true, features = ["termios", "fs", "process", "net", "event"] } russh-sftp = "3.0" uuid = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] landlock = "0.4" seccompiler = "0.5" -socket2 = { workspace = true } +socket2 = { workspace = true, features = ["all"] } tempfile = "3" [dev-dependencies] diff --git a/crates/openshell-sandbox/README.md b/crates/openshell-sandbox/README.md new file mode 100644 index 0000000000..b43b21c56b --- /dev/null +++ b/crates/openshell-sandbox/README.md @@ -0,0 +1,11 @@ +# Sandbox runtime + +`openshell-sandbox` owns the Linux workload isolation mechanisms and their kernel +qualification probes. The `linux` module contains seccomp notifications and child +filters, task-memory access, process-signal mediation, procfs descriptor lookup, +socket identity, Landlock qualification, and the workload launch thread. + +`sandbox::linux` composes policy enforcement. The top-level `linux` module owns +the lower-level mechanisms used by that orchestration and the boundary server. +Unsafe kernel ABI, process-launch, and descriptor-ownership operations stay in +this runtime crate behind safe APIs where their contracts can be enforced. diff --git a/crates/openshell-sandbox/src/boundary_exec.rs b/crates/openshell-sandbox/src/boundary_exec.rs index 5482629069..c0a4930a35 100644 --- a/crates/openshell-sandbox/src/boundary_exec.rs +++ b/crates/openshell-sandbox/src/boundary_exec.rs @@ -37,7 +37,7 @@ pub struct LocalBoundaryExec { user_environment: HashMap, runtime: Arc, #[cfg(target_os = "linux")] - launcher: openshell_isolation_interface::linux::workload_launcher::WorkloadLauncher, + launcher: crate::linux::workload_launcher::WorkloadLauncher, } impl LocalBoundaryExec { @@ -50,8 +50,7 @@ impl LocalBoundaryExec { provider_credentials: ProviderCredentialState, user_environment: HashMap, runtime: Arc, - #[cfg(target_os = "linux")] - launcher: openshell_isolation_interface::linux::workload_launcher::WorkloadLauncher, + #[cfg(target_os = "linux")] launcher: crate::linux::workload_launcher::WorkloadLauncher, ) -> Self { Self { policy, @@ -217,9 +216,8 @@ impl LocalBoundaryExec { #[cfg(target_os = "linux")] let prepared = self.prepare_sandbox(effective_workdir)?; #[cfg(target_os = "linux")] - let child_hardening = - openshell_isolation_interface::linux::child_seccomp::prepare(std::process::id()) - .map_err(|error| BackendError::Process(error.to_string()))?; + let child_hardening = crate::linux::child_seccomp::prepare(std::process::id()) + .map_err(|error| BackendError::Process(error.to_string()))?; crate::pty::install_dedicated_process_group(&mut command); crate::pty::install_pre_exec_no_pty( &mut command, @@ -329,9 +327,8 @@ impl LocalBoundaryExec { #[cfg(target_os = "linux")] let prepared = self.prepare_sandbox(effective_workdir)?; #[cfg(target_os = "linux")] - let child_hardening = - openshell_isolation_interface::linux::child_seccomp::prepare(std::process::id()) - .map_err(|error| BackendError::Process(error.to_string()))?; + let child_hardening = crate::linux::child_seccomp::prepare(std::process::id()) + .map_err(|error| BackendError::Process(error.to_string()))?; crate::pty::install_pre_exec( &mut command, self.policy.clone(), @@ -534,7 +531,7 @@ struct LocalTerminal { impl BoundaryTerminal for LocalTerminal { async fn resize(&self, cols: u16, rows: u16) -> Result<(), BackendError> { crate::pty::set_winsize( - self.master.as_raw_fd(), + &self.master, Winsize { ws_row: rows.max(1), ws_col: cols.max(1), @@ -684,8 +681,8 @@ mod tests { use tokio::io::{AsyncReadExt, AsyncWriteExt}; fn executor() -> LocalBoundaryExec { - let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() - .expect("start test workload launcher"); + let (launcher, listener) = + crate::linux::workload_launcher::start().expect("start test workload launcher"); std::thread::spawn(move || { while let Ok(notification) = listener.receive() { let syscall = i64::from(notification.syscall); diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index f6e08e78aa..16299730c9 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -211,14 +211,7 @@ mod linux { std::fs::remove_file(config_path).map_err(|error| { format!("consume boundary config {}: {error}", config_path.display()) })?; - let child_env = serde_json::to_string(&config.child_env) - .map_err(|error| format!("encode boundary workload environment: {error}"))?; - // This runs before the Tokio runtime or control threads exist. The process - // supervisor consumes the serialized map and applies values only to - // workload children. - unsafe { - std::env::set_var(openshell_core::sandbox_env::USER_ENVIRONMENT, child_env); - } + crate::process::install_boundary_user_environment(config.child_env.clone())?; crate::sandbox::apply_supervisor_startup_hardening() .map_err(|error| format!("install sandbox process prelude: {error}"))?; // Keep orphaned workload descendants in this process tree so @@ -230,7 +223,7 @@ mod linux { } crate::managed_children::start_orphan_reaper() .map_err(|error| format!("start sandbox orphan reaper: {error}"))?; - let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() + let (launcher, listener) = crate::linux::workload_launcher::start() .map_err(|error| format!("start sandbox workload launcher: {error}"))?; let protected_control_port = match &config.listener { BoundaryListenerConfig::TlsTcp { address, .. } => Some(address.port()), @@ -253,32 +246,19 @@ mod linux { } fn make_boundary_nondumpable() -> Result<(), String> { - // SAFETY: PR_SET_DUMPABLE accepts one scalar flag. The sandbox keeps - // bootstrap and protected-channel keys in memory after this point. - if unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0) } == 0 { - Ok(()) - } else { - Err(format!( - "make sandbox process nondumpable: {}", - io::Error::last_os_error() - )) - } + rustix::process::set_dumpable_behavior(rustix::process::DumpableBehavior::NotDumpable) + .map_err(|error| format!("make sandbox process nondumpable: {error}")) } fn disable_core_dumps() -> Result<(), String> { - let limit = libc::rlimit { - rlim_cur: 0, - rlim_max: 0, - }; - // SAFETY: `limit` is a valid immutable rlimit value. - if unsafe { libc::setrlimit(libc::RLIMIT_CORE, &raw const limit) } == 0 { - Ok(()) - } else { - Err(format!( - "disable sandbox core dumps: {}", - io::Error::last_os_error() - )) - } + rustix::process::setrlimit( + rustix::process::Resource::Core, + rustix::process::Rlimit { + current: Some(0), + maximum: Some(0), + }, + ) + .map_err(|error| format!("disable sandbox core dumps: {error}")) } fn install_boundary_signal_handlers() -> Result<(), String> { @@ -460,63 +440,27 @@ mod linux { expected: &ResolvedWorkloadIdentity, allow_runtime_supplementary_groups: bool, ) -> Result<(), String> { - let mut real_uid = 0; - let mut effective_uid = 0; - let mut saved_uid = 0; - let mut real_gid = 0; - let mut effective_gid = 0; - let mut saved_gid = 0; - // SAFETY: all pointers refer to live scalar output storage. - if unsafe { - libc::getresuid( - &raw mut real_uid, - &raw mut effective_uid, - &raw mut saved_uid, - ) - } != 0 - || unsafe { - libc::getresgid( - &raw mut real_gid, - &raw mut effective_gid, - &raw mut saved_gid, - ) - } != 0 - { - return Err(format!( - "measure sandbox identity: {}", - io::Error::last_os_error() - )); - } - if [real_uid, effective_uid, saved_uid] + let uid = nix::unistd::getresuid() + .map_err(|error| format!("measure sandbox identity: {error}"))?; + let gid = nix::unistd::getresgid() + .map_err(|error| format!("measure sandbox identity: {error}"))?; + if [uid.real, uid.effective, uid.saved] .iter() - .any(|uid| *uid != expected.uid) - || [real_gid, effective_gid, saved_gid] + .any(|uid| uid.as_raw() != expected.uid) + || [gid.real, gid.effective, gid.saved] .iter() - .any(|gid| *gid != expected.gid) + .any(|gid| gid.as_raw() != expected.gid) { return Err(format!( "sandbox identity does not match resolved workload {}:{}", expected.uid, expected.gid )); } - // SAFETY: a null buffer with size zero queries the group count. - let count = unsafe { libc::getgroups(0, std::ptr::null_mut()) }; - if count < 0 { - return Err(format!( - "measure sandbox supplementary groups: {}", - io::Error::last_os_error() - )); - } - let mut groups = vec![0_u32; usize::try_from(count).unwrap_or(0)]; - if count > 0 { - // SAFETY: groups has capacity for exactly `count` gid_t values. - if unsafe { libc::getgroups(count, groups.as_mut_ptr()) } != count { - return Err(format!( - "read sandbox supplementary groups: {}", - io::Error::last_os_error() - )); - } - } + let groups = nix::unistd::getgroups() + .map_err(|error| format!("read sandbox supplementary groups: {error}"))? + .into_iter() + .map(nix::unistd::Gid::as_raw) + .collect(); let groups = normalized_supplementary_groups(groups, expected.gid); if !supplementary_groups_match( &groups, @@ -1387,8 +1331,7 @@ mod linux { exec_requests: Mutex, replay_ledger: Mutex, network_broker: NetworkBroker, - workload_launcher: - openshell_isolation_interface::linux::workload_launcher::WorkloadLauncher, + workload_launcher: crate::linux::workload_launcher::WorkloadLauncher, qualification: crate::RuntimeQualification, } @@ -1634,7 +1577,7 @@ mod linux { config: BoundaryConfig, process_runtime: tokio::runtime::Handle, network_broker: NetworkBroker, - workload_launcher: openshell_isolation_interface::linux::workload_launcher::WorkloadLauncher, + workload_launcher: crate::linux::workload_launcher::WorkloadLauncher, qualification: crate::RuntimeQualification, ) -> Result { let sandbox_id = SandboxId::parse(config.boundary_id.clone()) @@ -2496,23 +2439,16 @@ mod linux { let no_new_privileges = parse_status_decimal(&status, "NoNewPrivs")? == 1; // SAFETY: PR_GET_DUMPABLE reads one scalar process property. let sandbox_dumpable = unsafe { libc::prctl(libc::PR_GET_DUMPABLE, 0, 0, 0, 0) } != 0; - let mut core_limit = std::mem::MaybeUninit::::uninit(); - // SAFETY: getrlimit initializes the supplied output value on success. - if unsafe { libc::getrlimit(libc::RLIMIT_CORE, core_limit.as_mut_ptr()) } != 0 { - return Err(format!( - "read sandbox core limit: {}", - io::Error::last_os_error() - )); - } - // SAFETY: successful getrlimit initialized the value. - let core_limit = unsafe { core_limit.assume_init() }; + let (core_current, core_maximum) = + nix::sys::resource::getrlimit(nix::sys::resource::Resource::RLIMIT_CORE) + .map_err(|error| format!("read sandbox core limit: {error}"))?; let (native_architecture, kernel_release) = uname_values()?; let audit = NativeLinuxSandboxAuditEvidence { capabilities, no_new_privileges, sandbox_dumpable, child_dumpable: true, - core_limit_zero: core_limit.rlim_cur == 0 && core_limit.rlim_max == 0, + core_limit_zero: core_current == 0 && core_maximum == 0, native_architecture, kernel_release, seccomp: self.qualification.seccomp, @@ -2837,29 +2773,12 @@ mod linux { } fn uname_values() -> Result<(String, String), String> { - let mut value = std::mem::MaybeUninit::::zeroed(); - // SAFETY: uname initializes the supplied utsname value on success. - if unsafe { libc::uname(value.as_mut_ptr()) } != 0 { - return Err(format!( - "measure sandbox kernel: {}", - io::Error::last_os_error() - )); - } - // SAFETY: successful uname initialized every fixed-size C string. - let value = unsafe { value.assume_init() }; - Ok((c_char_array(&value.machine), c_char_array(&value.release))) - } - - fn c_char_array(value: &[libc::c_char]) -> String { - let length = value - .iter() - .position(|byte| *byte == 0) - .unwrap_or(value.len()); - let bytes = value[..length] - .iter() - .map(|byte| byte.to_ne_bytes()[0]) - .collect::>(); - String::from_utf8_lossy(&bytes).into_owned() + let value = nix::sys::utsname::uname() + .map_err(|error| format!("measure sandbox kernel identity: {error}"))?; + Ok(( + value.machine().to_string_lossy().into_owned(), + value.release().to_string_lossy().into_owned(), + )) } impl PreparedBoundary { @@ -3043,7 +2962,7 @@ mod linux { impl ManagedProcess { fn spawn( runtime: &tokio::runtime::Handle, - launcher: &openshell_isolation_interface::linux::workload_launcher::WorkloadLauncher, + launcher: &crate::linux::workload_launcher::WorkloadLauncher, launch: ManagedProcessLaunch, _prepared: PreparedBoundary, ) -> Result { @@ -3500,28 +3419,9 @@ mod linux { } fn reject_workload_unix_peer(stream: &std::os::unix::net::UnixStream) -> io::Result<()> { - let mut credentials = libc::ucred { - pid: 0, - uid: 0, - gid: 0, - }; - let mut length = - libc::socklen_t::try_from(size_of::()).map_err(io::Error::other)?; - // SAFETY: both output pointers reference initialized storage of the - // declared length, and stream owns the connected Unix descriptor. - if unsafe { - libc::getsockopt( - stream.as_raw_fd(), - libc::SOL_SOCKET, - libc::SO_PEERCRED, - (&raw mut credentials).cast(), - &raw mut length, - ) - } != 0 - { - return Err(io::Error::last_os_error()); - } - let peer = u32::try_from(credentials.pid) + let credentials = + nix::sys::socket::getsockopt(stream, nix::sys::socket::sockopt::PeerCredentials)?; + let peer = u32::try_from(credentials.pid()) .map_err(|_| io::Error::from_raw_os_error(libc::EACCES))?; // Linux reports PID zero for a peer outside our PID namespace. Such a // peer still must authenticate with the per-sandbox mTLS certificate. @@ -3585,7 +3485,7 @@ mod linux { // The private channel directory is driver-provisioned. Requiring the // stale inode to have been created by this exact sandbox identity // prevents a replacement run from unlinking another principal's path. - if metadata.uid() != unsafe { libc::geteuid() } { + if metadata.uid() != nix::unistd::geteuid().as_raw() { return Err(io::Error::new( io::ErrorKind::PermissionDenied, format!( @@ -4755,11 +4655,10 @@ mod linux { fn test_network_broker() -> ( NetworkBroker, - openshell_isolation_interface::linux::workload_launcher::WorkloadLauncher, + crate::linux::workload_launcher::WorkloadLauncher, ) { let (launcher, listener) = - openshell_isolation_interface::linux::workload_launcher::start() - .expect("start test listener"); + crate::linux::workload_launcher::start().expect("start test listener"); ( NetworkBroker::start_for_test(listener).expect("start test network broker"), launcher, @@ -5162,8 +5061,7 @@ mod linux { #[tokio::test(flavor = "multi_thread")] async fn grpc_server_dispatches_authenticated_logical_streams() { let (workload_launcher, listener) = - openshell_isolation_interface::linux::workload_launcher::start() - .expect("start multiplexed test listener"); + crate::linux::workload_launcher::start().expect("start multiplexed test listener"); let network_broker = NetworkBroker::start_for_test(listener).expect("start multiplexed test broker"); let (verification_key, token) = test_auth_material("sandbox-multiplexed"); diff --git a/crates/openshell-sandbox/src/delegated.rs b/crates/openshell-sandbox/src/delegated.rs index f594fa35f2..e942a2ff2d 100644 --- a/crates/openshell-sandbox/src/delegated.rs +++ b/crates/openshell-sandbox/src/delegated.rs @@ -28,7 +28,7 @@ fn ocsf_ctx() -> &'static openshell_ocsf::EventContext { /// inside its boundary. #[allow(clippy::too_many_arguments, clippy::implicit_hasher)] pub async fn spawn_workload( - launcher: &openshell_isolation_interface::linux::workload_launcher::WorkloadLauncher, + launcher: &crate::linux::workload_launcher::WorkloadLauncher, program: &str, args: &[String], workdir: Option<&str>, @@ -69,11 +69,7 @@ pub async fn spawn_workload( let boundary_runtime = boundary_runtime .unwrap_or_else(crate::boundary_io::BoundaryRuntimeState::new_exclusive_pid_namespace); - let mut user_environment: std::collections::HashMap = - std::env::var(openshell_core::sandbox_env::USER_ENVIRONMENT) - .ok() - .and_then(|json| serde_json::from_str(&json).ok()) - .unwrap_or_default(); + let mut user_environment = crate::process::configured_user_environment(); user_environment.retain(|key, _value| !crate::process::is_proxy_env_var(key)); let loopback_connector: Arc = Arc::new( crate::boundary_io::LocalLoopbackConnector::new(Some(boundary_runtime.clone())), diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 110a395842..0b75a45f99 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -11,6 +11,8 @@ pub mod container_log; #[cfg(target_os = "linux")] pub(crate) mod delegated; #[cfg(target_os = "linux")] +pub mod linux; +#[cfg(target_os = "linux")] pub mod main_session; pub mod managed_children; #[cfg(target_os = "linux")] diff --git a/crates/openshell-isolation-interface/src/linux/child_seccomp.rs b/crates/openshell-sandbox/src/linux/child_seccomp.rs similarity index 100% rename from crates/openshell-isolation-interface/src/linux/child_seccomp.rs rename to crates/openshell-sandbox/src/linux/child_seccomp.rs diff --git a/crates/openshell-isolation-interface/src/linux/landlock.rs b/crates/openshell-sandbox/src/linux/landlock.rs similarity index 100% rename from crates/openshell-isolation-interface/src/linux/landlock.rs rename to crates/openshell-sandbox/src/linux/landlock.rs diff --git a/crates/openshell-isolation-interface/src/linux/mod.rs b/crates/openshell-sandbox/src/linux/mod.rs similarity index 100% rename from crates/openshell-isolation-interface/src/linux/mod.rs rename to crates/openshell-sandbox/src/linux/mod.rs diff --git a/crates/openshell-isolation-interface/src/linux/proc_fd.rs b/crates/openshell-sandbox/src/linux/proc_fd.rs similarity index 74% rename from crates/openshell-isolation-interface/src/linux/proc_fd.rs rename to crates/openshell-sandbox/src/linux/proc_fd.rs index 13f1856e54..4306f6d79a 100644 --- a/crates/openshell-isolation-interface/src/linux/proc_fd.rs +++ b/crates/openshell-sandbox/src/linux/proc_fd.rs @@ -3,8 +3,6 @@ //! Strict `/proc//fd` socket identity helpers. -#![allow(unsafe_code)] - use std::fs; use std::io; use std::os::fd::RawFd; @@ -100,27 +98,14 @@ pub fn socket_inode(tid: u32, fd: RawFd) -> io::Result { #[cfg(test)] mod tests { use std::fs::File; - use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; + use std::os::fd::AsRawFd; + use std::os::unix::net::UnixStream; use super::*; #[test] fn identifies_socket_and_rejects_regular_file() { - let mut pair = [-1; 2]; - // SAFETY: pair points to storage for exactly two returned descriptors. - let result = unsafe { - libc::socketpair( - libc::AF_UNIX, - libc::SOCK_STREAM | libc::SOCK_CLOEXEC, - 0, - pair.as_mut_ptr(), - ) - }; - assert_eq!(result, 0, "socketpair: {}", io::Error::last_os_error()); - // SAFETY: successful socketpair returned two independently owned FDs. - let left = unsafe { OwnedFd::from_raw_fd(pair[0]) }; - // SAFETY: successful socketpair returned two independently owned FDs. - let _right = unsafe { OwnedFd::from_raw_fd(pair[1]) }; + let (left, _right) = UnixStream::pair().expect("socketpair"); assert!(socket_inode(std::process::id(), left.as_raw_fd()).unwrap() > 0); let file = File::open("/dev/null").expect("open regular descriptor"); @@ -134,21 +119,7 @@ mod tests { #[test] fn installed_socket_snapshot_can_exclude_the_broker() { - let mut pair = [-1; 2]; - // SAFETY: pair points to storage for exactly two returned descriptors. - let result = unsafe { - libc::socketpair( - libc::AF_UNIX, - libc::SOCK_STREAM | libc::SOCK_CLOEXEC, - 0, - pair.as_mut_ptr(), - ) - }; - assert_eq!(result, 0, "socketpair: {}", io::Error::last_os_error()); - // SAFETY: successful socketpair returned two independently owned FDs. - let left = unsafe { OwnedFd::from_raw_fd(pair[0]) }; - // SAFETY: successful socketpair returned two independently owned FDs. - let _right = unsafe { OwnedFd::from_raw_fd(pair[1]) }; + let (left, _right) = UnixStream::pair().expect("socketpair"); let inode = socket_inode(std::process::id(), left.as_raw_fd()).unwrap(); assert!( diff --git a/crates/openshell-isolation-interface/src/linux/process_signal.rs b/crates/openshell-sandbox/src/linux/process_signal.rs similarity index 100% rename from crates/openshell-isolation-interface/src/linux/process_signal.rs rename to crates/openshell-sandbox/src/linux/process_signal.rs diff --git a/crates/openshell-isolation-interface/src/linux/seccomp_notify.rs b/crates/openshell-sandbox/src/linux/seccomp_notify.rs similarity index 90% rename from crates/openshell-isolation-interface/src/linux/seccomp_notify.rs rename to crates/openshell-sandbox/src/linux/seccomp_notify.rs index 5ae24cd27d..fcb2b705a5 100644 --- a/crates/openshell-isolation-interface/src/linux/seccomp_notify.rs +++ b/crates/openshell-sandbox/src/linux/seccomp_notify.rs @@ -428,25 +428,13 @@ fn probe_addfd_send() -> io::Result<()> { .map_err(|_| io::Error::other("injected descriptor does not fit RawFd"))?; // SAFETY: ADDFD-SEND returned one newly owned descriptor to this task. let injected = unsafe { OwnedFd::from_raw_fd(injected) }; - // SAFETY: `injected` was returned as an open descriptor by the kernel. - let descriptor_flags = unsafe { libc::fcntl(injected.as_raw_fd(), libc::F_GETFD) }; - if descriptor_flags < 0 { - return Err(io::Error::last_os_error()); - } - if descriptor_flags & libc::FD_CLOEXEC == 0 { + let descriptor_flags = rustix::io::fcntl_getfd(&injected)?; + if !descriptor_flags.contains(rustix::io::FdFlags::CLOEXEC) { return Err(io::Error::other("ADDFD did not preserve close-on-exec")); } - let mut value = 0_u64; - // SAFETY: eventfd reads exactly one u64 into a valid aligned pointer. - let read = unsafe { - libc::read( - injected.as_raw_fd(), - std::ptr::addr_of_mut!(value).cast(), - size_of::(), - ) - }; - let word_size = isize::try_from(size_of::()).map_err(io::Error::other)?; - if read != word_size || value != 7 { + let mut value = [0_u8; size_of::()]; + let read = rustix::io::read(&injected, &mut value[..]); + if read != Ok(value.len()) || u64::from_ne_bytes(value) != 7 { return Err(io::Error::other("injected eventfd was not usable")); } Ok(()) @@ -465,13 +453,7 @@ fn probe_addfd_send() -> io::Result<()> { if i64::from(notification.syscall) != libc::SYS_socket { return Err(io::Error::other("unexpected ADDFD probe syscall")); } - // SAFETY: eventfd has no pointer arguments and returns an owned descriptor. - let source = unsafe { libc::eventfd(7, libc::EFD_CLOEXEC) }; - if source < 0 { - return Err(io::Error::last_os_error()); - } - // SAFETY: eventfd returned a new owned descriptor. - let source = unsafe { OwnedFd::from_raw_fd(source) }; + let source = rustix::event::eventfd(7, rustix::event::EventfdFlags::CLOEXEC)?; listener.add_fd_and_send(notification.id, source.as_raw_fd(), true)?; launcher .join() @@ -480,23 +462,7 @@ fn probe_addfd_send() -> io::Result<()> { } fn probe_connected_sendto_fast_path() -> io::Result<()> { - let mut pair = [-1; 2]; - // SAFETY: `pair` points to storage for exactly two returned descriptors. - let result = unsafe { - libc::socketpair( - libc::AF_UNIX, - libc::SOCK_STREAM | libc::SOCK_CLOEXEC, - 0, - pair.as_mut_ptr(), - ) - }; - if result < 0 { - return Err(io::Error::last_os_error()); - } - // SAFETY: successful socketpair returned two independently owned FDs. - let sender_fd = unsafe { OwnedFd::from_raw_fd(pair[0]) }; - // SAFETY: successful socketpair returned two independently owned FDs. - let receiver_fd = unsafe { OwnedFd::from_raw_fd(pair[1]) }; + let (sender_fd, receiver_fd) = std::os::unix::net::UnixStream::pair()?; let (sender, receiver) = mpsc::sync_channel(1); let launcher = thread::spawn(move || -> io::Result<()> { @@ -524,15 +490,8 @@ fn probe_connected_sendto_fast_path() -> io::Result<()> { } let mut payload = [0_u8; 6]; - // SAFETY: the receive buffer is live for its full declared length. - let read = unsafe { - libc::read( - receiver_fd.as_raw_fd(), - payload.as_mut_ptr().cast(), - payload.len(), - ) - }; - if read != isize::try_from(payload.len()).map_err(io::Error::other)? || &payload != direct { + let read = rustix::io::read(&receiver_fd, &mut payload[..]); + if read != Ok(payload.len()) || &payload != direct { return Err(io::Error::other( "connected sendto fast path did not relay data", )); @@ -628,25 +587,26 @@ fn probe_connected_sendto_fast_path() -> io::Result<()> { } fn receive_probe_notification(listener: &NotificationListener) -> io::Result { - let mut descriptor = libc::pollfd { - fd: listener.as_raw_fd(), - events: libc::POLLIN | libc::POLLHUP, - revents: 0, - }; + use std::os::fd::AsFd as _; + + let mut descriptors = [nix::poll::PollFd::new( + listener.fd.as_fd(), + nix::poll::PollFlags::POLLIN | nix::poll::PollFlags::POLLHUP, + )]; let timeout = - i32::try_from(PROBE_NOTIFICATION_TIMEOUT.as_millis()).map_err(io::Error::other)?; - // SAFETY: descriptor points to one live pollfd for the duration of poll. - let ready = unsafe { libc::poll(&raw mut descriptor, 1, timeout) }; - if ready < 0 { - return Err(io::Error::last_os_error()); - } + nix::poll::PollTimeout::try_from(PROBE_NOTIFICATION_TIMEOUT).map_err(io::Error::other)?; + let ready = nix::poll::poll(&mut descriptors, timeout)?; if ready == 0 { return Err(io::Error::new( io::ErrorKind::TimedOut, "seccomp notification probe timed out", )); } - if descriptor.revents & libc::POLLIN == 0 { + if !descriptors[0] + .revents() + .unwrap_or_else(nix::poll::PollFlags::empty) + .contains(nix::poll::PollFlags::POLLIN) + { return Err(io::Error::new( io::ErrorKind::BrokenPipe, "seccomp notification probe listener closed", @@ -901,7 +861,7 @@ mod tests { // inspect this trusted process through /proc/self/mem: a // nondumpable non-root process cannot open that file, while the // separately qualified dumpable workload child remains readable. - if unsafe { libc::geteuid() } == 0 { + if rustix::process::geteuid().as_raw() == 0 { // SAFETY: this disposable subprocess permanently drops its // supplementary groups and root identity before probing. assert_eq!(unsafe { libc::setgroups(0, std::ptr::null()) }, 0); @@ -980,14 +940,9 @@ mod tests { #[test] fn errno_response_rejects_nonpositive_values() { // The input validation occurs before the listener FD is used. - // SAFETY: dup takes one valid descriptor and returns a new descriptor - // or a negative error without modifying memory. - let duplicated = unsafe { libc::dup(libc::STDERR_FILENO) }; - assert!(duplicated >= 0, "duplicate stderr for validation test"); - let listener = NotificationListener { - // SAFETY: successful dup returned a new owned descriptor. - fd: unsafe { OwnedFd::from_raw_fd(duplicated) }, - }; + let duplicated = + rustix::io::dup(io::stderr()).expect("duplicate stderr for validation test"); + let listener = NotificationListener { fd: duplicated }; let error = listener .respond_errno(1, 0) .expect_err("zero errno must fail"); diff --git a/crates/openshell-isolation-interface/src/linux/socket_confinement.rs b/crates/openshell-sandbox/src/linux/socket_confinement.rs similarity index 100% rename from crates/openshell-isolation-interface/src/linux/socket_confinement.rs rename to crates/openshell-sandbox/src/linux/socket_confinement.rs diff --git a/crates/openshell-isolation-interface/src/linux/socket_registry.rs b/crates/openshell-sandbox/src/linux/socket_registry.rs similarity index 87% rename from crates/openshell-isolation-interface/src/linux/socket_registry.rs rename to crates/openshell-sandbox/src/linux/socket_registry.rs index 9754720792..984e7545ae 100644 --- a/crates/openshell-isolation-interface/src/linux/socket_registry.rs +++ b/crates/openshell-sandbox/src/linux/socket_registry.rs @@ -3,13 +3,10 @@ //! Bounded registry for socket-time seccomp virtualization. -#![allow(unsafe_code)] - use std::collections::BTreeMap; use std::io; -use std::mem::size_of; use std::net::SocketAddr; -use std::os::fd::{AsRawFd, BorrowedFd, OwnedFd, RawFd}; +use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd, RawFd}; use rustix::fs::fstat; @@ -134,7 +131,7 @@ impl SocketEntry { /// inode. pub fn validate_retained_identity(&self) -> io::Result<()> { let retained = self.retained_preconnect()?; - let identity = socket_identity(retained.as_raw_fd(), self.identity.listener_generation)?; + let identity = socket_identity(retained.as_fd(), self.identity.listener_generation)?; if identity == self.identity { Ok(()) } else { @@ -231,7 +228,7 @@ impl SocketRegistry { if self.entries.len() >= self.capacity { return Err(io::Error::from_raw_os_error(libc::EMFILE)); } - let identity = socket_identity(source.as_raw_fd(), self.listener_generation)?; + let identity = socket_identity(source.as_fd(), self.listener_generation)?; if self.entries.contains_key(&identity.inode) { return Err(io::Error::new( io::ErrorKind::AlreadyExists, @@ -324,33 +321,16 @@ impl SocketRegistry { } } -fn socket_identity(fd: RawFd, listener_generation: u64) -> io::Result { - // SAFETY: `fd` remains open for this function; the borrow never escapes. - let borrowed = unsafe { BorrowedFd::borrow_raw(fd) }; - let stat = fstat(borrowed)?; +fn socket_identity(fd: BorrowedFd<'_>, listener_generation: u64) -> io::Result { + let stat = fstat(fd)?; if stat.st_mode & libc::S_IFMT != libc::S_IFSOCK { return Err(io::Error::new( io::ErrorKind::InvalidInput, "registry source descriptor is not a socket", )); } - let mut cookie = 0_u64; - let mut length = - libc::socklen_t::try_from(size_of::()).expect("SO_COOKIE length fits socklen_t"); - // SAFETY: getsockopt writes at most the supplied u64 and socklen_t. - let result = unsafe { - libc::getsockopt( - fd, - libc::SOL_SOCKET, - libc::SO_COOKIE, - std::ptr::addr_of_mut!(cookie).cast(), - std::ptr::addr_of_mut!(length), - ) - }; - if result < 0 { - return Err(io::Error::last_os_error()); - } - if usize::try_from(length).ok() != Some(size_of::()) || cookie == 0 { + let cookie = rustix::net::sockopt::socket_cookie(fd)?; + if cookie == 0 { return Err(io::Error::new( io::ErrorKind::InvalidData, "kernel returned an invalid SO_COOKIE", @@ -365,22 +345,18 @@ fn socket_identity(fd: RawFd, listener_generation: u64) -> io::Result OwnedFd { - // SAFETY: socket returns one newly owned descriptor on success. - let fd = unsafe { - libc::socket( - libc::AF_INET, - libc::SOCK_STREAM | libc::SOCK_CLOEXEC, - libc::IPPROTO_TCP, - ) - }; - assert!(fd >= 0, "socket: {}", io::Error::last_os_error()); - // SAFETY: successful socket returned one owned descriptor. - unsafe { OwnedFd::from_raw_fd(fd) } + rustix::net::socket_with( + AddressFamily::INET, + SocketType::STREAM, + SocketFlags::CLOEXEC, + Some(ipproto::TCP), + ) + .expect("socket") } fn metadata() -> SocketMetadata { @@ -428,13 +404,7 @@ mod tests { fn dup_alias_resolves_to_same_open_file_description() { let mut registry = SocketRegistry::new(9, 4).unwrap(); let socket = tcp_socket(); - let original_fd = socket.as_raw_fd(); - // SAFETY: dup returns a new descriptor for the same open-file - // description or a negative error. - let alias_fd = unsafe { libc::dup(original_fd) }; - assert!(alias_fd >= 0, "dup: {}", io::Error::last_os_error()); - // SAFETY: successful dup returned one owned descriptor. - let alias = unsafe { OwnedFd::from_raw_fd(alias_fd) }; + let alias = rustix::io::dup(&socket).expect("dup"); let tentative = registry.stage(socket, metadata()).unwrap(); let identity = registry.commit(tentative).unwrap(); diff --git a/crates/openshell-isolation-interface/src/linux/task_memory.rs b/crates/openshell-sandbox/src/linux/task_memory.rs similarity index 83% rename from crates/openshell-isolation-interface/src/linux/task_memory.rs rename to crates/openshell-sandbox/src/linux/task_memory.rs index ed34f234e8..df2afa9925 100644 --- a/crates/openshell-isolation-interface/src/linux/task_memory.rs +++ b/crates/openshell-sandbox/src/linux/task_memory.rs @@ -11,7 +11,7 @@ use std::io; use std::mem::size_of; -use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; +use std::os::fd::AsFd; use std::os::unix::fs::FileExt as _; /// Maximum number of task-memory bytes copied by one operation. @@ -124,32 +124,29 @@ pub fn probe_child_access() -> io::Result<()> { // SAFETY: mapping spans at least one aligned u64-sized region. unsafe { mapping.cast::().write(INITIAL) }; - // SAFETY: eventfd returns independently owned descriptors on success. - let ready = unsafe { libc::eventfd(0, libc::EFD_CLOEXEC) }; - if ready < 0 { - // SAFETY: mapping is the live region returned above. - unsafe { libc::munmap(mapping, size_of::()) }; - return Err(io::Error::last_os_error()); - } - // SAFETY: successful eventfd returned one owned descriptor. - let ready = unsafe { OwnedFd::from_raw_fd(ready) }; - // SAFETY: eventfd returns independently owned descriptors on success. - let proceed = unsafe { libc::eventfd(0, libc::EFD_CLOEXEC) }; - if proceed < 0 { - // SAFETY: mapping is the live region returned above. - unsafe { libc::munmap(mapping, size_of::()) }; - return Err(io::Error::last_os_error()); - } - // SAFETY: successful eventfd returned one owned descriptor. - let proceed = unsafe { OwnedFd::from_raw_fd(proceed) }; + // Preserve descriptor creation errors across mapping cleanup. + let descriptors = (|| -> io::Result<_> { + let ready = rustix::event::eventfd(0, rustix::event::EventfdFlags::CLOEXEC)?; + let proceed = rustix::event::eventfd(0, rustix::event::EventfdFlags::CLOEXEC)?; + Ok((ready, proceed)) + })(); + let (ready, proceed) = match descriptors { + Ok(descriptors) => descriptors, + Err(error) => { + // SAFETY: mapping is the live region returned above. + unsafe { libc::munmap(mapping, size_of::()) }; + return Err(error); + } + }; // SAFETY: the caller promises this probe process is single-threaded. The // child performs only raw syscalls and memory operations before `_exit`. let child = unsafe { libc::fork() }; if child < 0 { + let error = io::Error::last_os_error(); // SAFETY: mapping is the live region returned above. unsafe { libc::munmap(mapping, size_of::()) }; - return Err(io::Error::last_os_error()); + return Err(error); } if child == 0 { // The sandbox remains nondumpable, but an exec'd workload must be @@ -159,8 +156,8 @@ pub fn probe_child_access() -> io::Result<()> { // arguments. No Rust cleanup runs in the child. unsafe { if libc::prctl(libc::PR_SET_DUMPABLE, 1, 0, 0, 0) < 0 - || write_eventfd(ready.as_raw_fd()).is_err() - || read_eventfd(proceed.as_raw_fd()).is_err() + || write_eventfd(&ready).is_err() + || read_eventfd(&proceed).is_err() { libc::_exit(1); } @@ -169,7 +166,7 @@ pub fn probe_child_access() -> io::Result<()> { } let outcome = (|| { - read_eventfd(ready.as_raw_fd())?; + read_eventfd(&ready)?; let mut observed = [0_u8; size_of::()]; read_exact( u32::try_from(child).map_err(|_| io::Error::other("child PID does not fit u32"))?, @@ -181,7 +178,7 @@ pub fn probe_child_access() -> io::Result<()> { "cross-child memory read returned wrong data", )); } - write_eventfd(proceed.as_raw_fd())?; + write_eventfd(&proceed)?; let mut status = 0; // SAFETY: child is a live direct child and status points to storage. if unsafe { libc::waitpid(child, std::ptr::addr_of_mut!(status), 0) } != child { @@ -206,33 +203,18 @@ pub fn probe_child_access() -> io::Result<()> { outcome } -fn read_eventfd(fd: libc::c_int) -> io::Result<()> { - let mut value = 0_u64; - // SAFETY: eventfd reads exactly one u64 into live storage. - let result = unsafe { libc::read(fd, std::ptr::addr_of_mut!(value).cast(), size_of::()) }; - if result < 0 { - return Err(io::Error::last_os_error()); - } - require_exact( - usize::try_from(result).map_err(|_| io::Error::other("eventfd read length invalid"))?, - size_of::(), - "eventfd read", - ) +fn read_eventfd(fd: impl AsFd) -> io::Result<()> { + let mut value = [0_u8; size_of::()]; + let copied = rustix::io::read(fd, &mut value[..])?; + require_exact(copied, value.len(), "eventfd read") } -fn write_eventfd(fd: libc::c_int) -> io::Result<()> { - let value = 1_u64; - // SAFETY: eventfd reads exactly one u64 from live storage. - let result = unsafe { libc::write(fd, std::ptr::addr_of!(value).cast(), size_of::()) }; - if result < 0 { - return Err(io::Error::last_os_error()); - } - require_exact( - usize::try_from(result).map_err(|_| io::Error::other("eventfd write length invalid"))?, - size_of::(), - "eventfd write", - ) +fn write_eventfd(fd: impl AsFd) -> io::Result<()> { + let value = 1_u64.to_ne_bytes(); + let copied = rustix::io::write(fd, &value)?; + require_exact(copied, value.len(), "eventfd write") } + fn validate_request(tid: u32, address: u64, length: usize) -> io::Result<()> { if tid == 0 { return Err(io::Error::new( diff --git a/crates/openshell-isolation-interface/src/linux/workload_launcher.rs b/crates/openshell-sandbox/src/linux/workload_launcher.rs similarity index 94% rename from crates/openshell-isolation-interface/src/linux/workload_launcher.rs rename to crates/openshell-sandbox/src/linux/workload_launcher.rs index 71fc7f336e..51b3af629e 100644 --- a/crates/openshell-isolation-interface/src/linux/workload_launcher.rs +++ b/crates/openshell-sandbox/src/linux/workload_launcher.rs @@ -117,7 +117,7 @@ pub fn start() -> io::Result<(WorkloadLauncher, NotificationListener)> { #[allow(unsafe_code)] mod tests { use std::mem::size_of; - use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd}; + use std::os::fd::AsRawFd as _; use super::*; @@ -163,11 +163,8 @@ mod tests { std::path::Path::new(&format!("/proc/{}/task/{}", child.id(), notification.tid)) .exists() ); - // SAFETY: eventfd returns one newly owned descriptor on success. - let eventfd = unsafe { libc::eventfd(7, libc::EFD_CLOEXEC) }; - assert!(eventfd >= 0, "eventfd: {}", io::Error::last_os_error()); - // SAFETY: successful eventfd returned one owned descriptor. - let eventfd = unsafe { OwnedFd::from_raw_fd(eventfd) }; + let eventfd = rustix::event::eventfd(7, rustix::event::EventfdFlags::CLOEXEC) + .expect("create eventfd"); listener .add_fd_and_send(notification.id, eventfd.as_raw_fd(), true) .expect("inject child descriptor"); diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 514f2c1901..0095e68e7c 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -146,7 +146,7 @@ fn qualify_runtime() -> Result<(openshell_sandbox::RuntimeQualification, Qualifi std::io::Error::last_os_error() )); } - openshell_isolation_interface::linux::task_memory::probe_child_access() + openshell_sandbox::linux::task_memory::probe_child_access() .into_diagnostic() .wrap_err("same-UID task-memory probe")?; // The successful production-shaped parent-to-child round trip above is @@ -155,16 +155,15 @@ fn qualify_runtime() -> Result<(openshell_sandbox::RuntimeQualification, Qualifi // even though /proc//mem remains available to mediation. let task_memory_copy = true; probe_landlock_allow_deny().wrap_err("Landlock allow/deny probe")?; - let notification = - openshell_isolation_interface::linux::seccomp_notify::probe_notification_api() - .into_diagnostic() - .wrap_err("seccomp notification probe")?; + let notification = openshell_sandbox::linux::seccomp_notify::probe_notification_api() + .into_diagnostic() + .wrap_err("seccomp notification probe")?; probe_socket_virtualization().wrap_err("socket virtualization probe")?; - openshell_isolation_interface::linux::socket_confinement::probe_loopback_confinement() + openshell_sandbox::linux::socket_confinement::probe_loopback_confinement() .into_diagnostic() .wrap_err("socket loopback confinement probe")?; probe_dns_relay_bind().wrap_err("DNS relay bind probe")?; - let landlock_abi = openshell_isolation_interface::linux::landlock::abi_version() + let landlock_abi = openshell_sandbox::linux::landlock::abi_version() .into_diagnostic() .wrap_err("Landlock ABI probe")?; if landlock_abi < 3 { @@ -358,8 +357,8 @@ fn probe_socket_virtualization() -> Result<()> { use std::sync::mpsc; use miette::Context as _; - use openshell_isolation_interface::linux::seccomp_notify::NotificationListener; - use openshell_isolation_interface::linux::socket_registry::{ + use openshell_sandbox::linux::seccomp_notify::NotificationListener; + use openshell_sandbox::linux::socket_registry::{ InetFamily, InetKind, SocketMetadata, SocketRegistry, SocketState, }; @@ -434,10 +433,9 @@ fn probe_socket_virtualization() -> Result<()> { .into_diagnostic() .wrap_err("resolve socket probe executable")?; let sandbox_tgid = std::process::id(); - let mut child_hardening = - openshell_isolation_interface::linux::child_seccomp::prepare(sandbox_tgid) - .into_diagnostic() - .wrap_err("prepare socket probe child hardening")?; + let mut child_hardening = openshell_sandbox::linux::child_seccomp::prepare(sandbox_tgid) + .into_diagnostic() + .wrap_err("prepare socket probe child hardening")?; let (listener_tx, listener_rx) = mpsc::sync_channel::>(1); let (child_tx, child_rx) = mpsc::sync_channel::>(1); let launcher = std::thread::Builder::new() @@ -447,12 +445,11 @@ fn probe_socket_virtualization() -> Result<()> { let _ = listener_tx.send(Err(error)); return; } - let listener = - openshell_isolation_interface::linux::seccomp_notify::install_listener(&[ - libc::SYS_socket, - libc::SYS_connect, - libc::SYS_sendto, - ]); + let listener = openshell_sandbox::linux::seccomp_notify::install_listener(&[ + libc::SYS_socket, + libc::SYS_connect, + libc::SYS_sendto, + ]); let Ok(listener) = listener else { let _ = listener_tx.send(listener); return; @@ -660,7 +657,7 @@ fn probe_socket_virtualization() -> Result<()> { return Err(miette::miette!("unexpected DNS probe destination")); } let mut payload = vec![0_u8; length]; - openshell_isolation_interface::linux::task_memory::read_exact( + openshell_sandbox::linux::task_memory::read_exact( notification.tid, notification.args[1], &mut payload, @@ -795,19 +792,9 @@ fn block_launcher_signals() -> std::io::Result<()> { } #[cfg(target_os = "linux")] -#[allow(unsafe_code)] -unsafe fn set_child_core_limit() -> std::io::Result<()> { - let limit = libc::rlimit { - rlim_cur: 0, - rlim_max: 0, - }; - // SAFETY: `limit` is a live fixed-size rlimit and this child-only update - // permanently disables core dumps before any untrusted instruction. - if unsafe { libc::setrlimit(libc::RLIMIT_CORE, &raw const limit) } < 0 { - Err(std::io::Error::last_os_error()) - } else { - Ok(()) - } +fn set_child_core_limit() -> std::io::Result<()> { + nix::sys::resource::setrlimit(nix::sys::resource::Resource::RLIMIT_CORE, 0, 0) + .map_err(std::io::Error::from) } #[cfg(target_os = "linux")] @@ -868,7 +855,7 @@ fn read_probe_sockaddr(tid: u32, address: u64, length: u64) -> Result (u16, u16) { let master = self.pty_master.as_ref().expect("terminal PTY master"); - let mut winsize: libc::winsize = unsafe { std::mem::zeroed() }; - let result = unsafe { libc::ioctl(master.as_raw_fd(), libc::TIOCGWINSZ, &mut winsize) }; - assert_eq!(result, 0, "read terminal dimensions"); + let winsize = rustix::termios::tcgetwinsize(master).expect("read terminal dimensions"); (winsize.ws_col, winsize.ws_row) } @@ -956,10 +954,7 @@ impl MainSession { ws_xpixel: u16::try_from(pixel_width).unwrap_or(u16::MAX), ws_ypixel: u16::try_from(pixel_height).unwrap_or(u16::MAX), }; - #[allow(unsafe_code)] - unsafe { - libc::ioctl(master.as_raw_fd(), libc::TIOCSWINSZ, &winsize); - } + let _ = rustix::termios::tcsetwinsize(master, winsize); } pub async fn signal_group(&self, signal: nix::sys::signal::Signal) -> Result<(), String> { diff --git a/crates/openshell-sandbox/src/network_broker.rs b/crates/openshell-sandbox/src/network_broker.rs index 3266b55726..3f7d45381a 100644 --- a/crates/openshell-sandbox/src/network_broker.rs +++ b/crates/openshell-sandbox/src/network_broker.rs @@ -15,16 +15,16 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +use crate::linux::seccomp_notify::{Notification, NotificationListener}; +use crate::linux::socket_registry::{ + InetFamily, InetKind, SocketIdentity, SocketMetadata, SocketRegistry, SocketState, +}; +use crate::linux::task_memory; use openshell_binary_identity::ProcfsIdentityResolver; use openshell_isolation_interface::contract::{ BinaryIdentity, DnsTransport, NetworkSocketMetadata, ResolveError, TcpOpenDecision, TcpOpenDenial, }; -use openshell_isolation_interface::linux::seccomp_notify::{Notification, NotificationListener}; -use openshell_isolation_interface::linux::socket_registry::{ - InetFamily, InetKind, SocketIdentity, SocketMetadata, SocketRegistry, SocketState, -}; -use openshell_isolation_interface::linux::task_memory; use tokio::sync::{mpsc, oneshot}; const SOCKET_CAPACITY: usize = 4_096; @@ -153,9 +153,7 @@ fn register_dns_socket( let mut admissions = lock(admissions); if admissions.len() >= SOCKET_CAPACITY && !admissions.contains_key(&peer) { let installed = - openshell_isolation_interface::linux::proc_fd::installed_socket_inodes_excluding( - std::process::id(), - )?; + crate::linux::proc_fd::installed_socket_inodes_excluding(std::process::id())?; admissions.retain(|_, socket| installed.contains(&socket.inode)); if admissions.len() >= SOCKET_CAPACITY { return Err(io::Error::from_raw_os_error(libc::EMFILE)); @@ -561,7 +559,7 @@ fn dispatch_notification( return queues.provider_files.handle_open(&listener, notification); } if matches!(syscall, libc::SYS_kill | libc::SYS_rt_sigqueueinfo) { - return openshell_isolation_interface::linux::process_signal::mediate_process_signal( + return crate::linux::process_signal::mediate_process_signal( &listener, notification, std::process::id(), @@ -572,7 +570,7 @@ fn dispatch_notification( syscall, libc::SYS_tkill | libc::SYS_tgkill | libc::SYS_rt_tgsigqueueinfo ) { - return openshell_isolation_interface::linux::process_signal::mediate_thread_signal( + return crate::linux::process_signal::mediate_thread_signal( &listener, notification, std::process::id(), @@ -696,7 +694,7 @@ fn create_socket( // Confinement is standing kernel state that must exist before the workload // can observe the descriptor. Natively accepted children inherit it, so // local accept needs no per-connection broker inspection. - openshell_isolation_interface::linux::socket_confinement::confine_to_loopback(&source)?; + crate::linux::socket_confinement::confine_to_loopback(&source)?; let metadata = SocketMetadata { family, kind, @@ -719,15 +717,9 @@ fn create_socket( } fn descriptor_soft_limit() -> io::Result { - let mut limit = libc::rlimit { - rlim_cur: 0, - rlim_max: 0, - }; - // SAFETY: limit points to writable storage for one rlimit value. - if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &raw mut limit) } < 0 { - return Err(io::Error::last_os_error()); - } - Ok(usize::try_from(limit.rlim_cur).unwrap_or(usize::MAX)) + let (current, _) = nix::sys::resource::getrlimit(nix::sys::resource::Resource::RLIMIT_NOFILE) + .map_err(io::Error::from)?; + Ok(usize::try_from(current).unwrap_or(usize::MAX)) } fn open_descriptor_count() -> io::Result { @@ -1308,10 +1300,7 @@ fn collect_closed_socket_entries(registry: &Mutex) -> io::Result } fn collect_closed_socket_entries_locked(registry: &mut SocketRegistry) -> io::Result<()> { - let installed = - openshell_isolation_interface::linux::proc_fd::installed_socket_inodes_excluding( - std::process::id(), - )?; + let installed = crate::linux::proc_fd::installed_socket_inodes_excluding(std::process::id())?; registry.retain_installed(&installed); Ok(()) } @@ -1483,7 +1472,7 @@ fn read_sendto_message(notification: Notification) -> io::Result { } fn read_sendmsg_message(tid: u32, address: u64) -> io::Result { - let header = read_task_value::(tid, address)?; + let header = read_task_msghdr(tid, address)?; // Ancillary data can carry a per-message routing override (IP_PKTINFO). // Refuse it rather than continue a send the broker did not inspect; a // loopback destination additionally contains an override that races this @@ -1522,12 +1511,12 @@ fn read_sendmmsg_messages(notification: Notification) -> io::Result(tid: u32, address: u64) -> io::Result { - let mut bytes = vec![0_u8; size_of::()]; +fn read_task_msghdr(tid: u32, address: u64) -> io::Result { + let mut bytes = [0_u8; size_of::()]; task_memory::read_exact(tid, address, &mut bytes)?; - // SAFETY: `bytes` contains exactly one copied native value; unaligned read - // avoids imposing alignment on the task-memory scratch allocation. - Ok(unsafe { std::ptr::read_unaligned(bytes.as_ptr().cast::()) }) + // SAFETY: msghdr contains only integer and pointer fields, so all bit + // patterns are valid. The scratch buffer need not be aligned to msghdr. + Ok(unsafe { std::ptr::read_unaligned(bytes.as_ptr().cast::()) }) } fn connect_exact(fd: RawFd, address: SocketAddr) -> io::Result<()> { @@ -1733,12 +1722,12 @@ fn error_to_errno(error: &io::Error) -> i32 { #[cfg(test)] mod tests { use super::*; - use openshell_isolation_interface::linux::socket_confinement; + use crate::linux::socket_confinement; #[test] fn provider_files_are_opened_on_demand_and_replaced() { - let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() - .expect("start workload launcher"); + let (launcher, listener) = + crate::linux::workload_launcher::start().expect("start workload launcher"); let broker = NetworkBroker::start_for_test(listener).expect("start broker"); let path = "/run/openshell/providers/acme/client.toml".to_string(); broker @@ -2003,8 +1992,7 @@ mod tests { #[test] fn metadata_reservation_preserves_other_loopback_and_rejects_udp() { - let (launcher, listener) = - openshell_isolation_interface::linux::workload_launcher::start().unwrap(); + let (launcher, listener) = crate::linux::workload_launcher::start().unwrap(); let _broker = NetworkBroker::start_for_test(listener).unwrap(); let local_server = TcpListener::bind("127.0.0.1:0").unwrap(); let address = local_server.local_addr().unwrap(); @@ -2026,8 +2014,7 @@ mod tests { #[test] fn metadata_loopback_connect_is_relayed_to_supervisor() { use std::io::{Read as _, Write as _}; - let (launcher, listener) = - openshell_isolation_interface::linux::workload_launcher::start().unwrap(); + let (launcher, listener) = crate::linux::workload_launcher::start().unwrap(); let broker = NetworkBroker::start_for_test(listener).unwrap(); let client = std::thread::spawn(move || { launcher @@ -2072,8 +2059,8 @@ mod tests { #[test] fn external_connect_times_out_when_supervisor_retains_the_decision() { - let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() - .expect("start workload launcher"); + let (launcher, listener) = + crate::linux::workload_launcher::start().expect("start workload launcher"); let broker = NetworkBroker::start_with_decision_timeout( listener, "127.0.0.1:0".parse().unwrap(), @@ -2130,8 +2117,7 @@ mod tests { use std::process::{Command, Stdio}; for transport in [DnsTransport::Udp, DnsTransport::Tcp] { - let (launcher, listener) = - openshell_isolation_interface::linux::workload_launcher::start().unwrap(); + let (launcher, listener) = crate::linux::workload_launcher::start().unwrap(); let broker = NetworkBroker::start_for_test(listener).unwrap(); let address = broker.dns_address(); let child = std::thread::spawn(move || { @@ -2209,8 +2195,7 @@ mod tests { let control = TcpListener::bind("127.0.0.1:0").unwrap(); control.set_nonblocking(true).unwrap(); let address = control.local_addr().unwrap(); - let (launcher, listener) = - openshell_isolation_interface::linux::workload_launcher::start().unwrap(); + let (launcher, listener) = crate::linux::workload_launcher::start().unwrap(); let _broker = NetworkBroker::start_with_dns_address( listener, "127.0.0.1:0".parse().unwrap(), @@ -2271,8 +2256,8 @@ mod tests { let directory = tempfile::tempdir().expect("temporary Unix socket directory"); let path = directory.path().join("service.sock"); let service = UnixListener::bind(&path).expect("bind Unix service"); - let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() - .expect("start workload launcher"); + let (launcher, listener) = + crate::linux::workload_launcher::start().expect("start workload launcher"); let _broker = NetworkBroker::start_for_test(listener).expect("start network broker"); let client = std::thread::spawn(move || { launcher @@ -2291,8 +2276,8 @@ mod tests { #[test] fn native_accept_inherits_loopback_confinement() { - let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() - .expect("start workload launcher"); + let (launcher, listener) = + crate::linux::workload_launcher::start().expect("start workload launcher"); let _broker = NetworkBroker::start_for_test(listener).expect("start network broker"); let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1); let workload = std::thread::spawn(move || { @@ -2354,8 +2339,8 @@ mod tests { #[test] fn workload_sockets_are_bound_to_loopback_and_cannot_be_rebound() { - let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() - .expect("start workload launcher"); + let (launcher, listener) = + crate::linux::workload_launcher::start().expect("start workload launcher"); let _broker = NetworkBroker::start_for_test(listener).expect("start network broker"); let results = launcher .execute(|| -> io::Result> { @@ -2388,8 +2373,8 @@ mod tests { let local = TcpListener::bind("127.0.0.1:0").unwrap(); local.set_nonblocking(true).unwrap(); let address = local.local_addr().unwrap(); - let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() - .expect("start workload launcher"); + let (launcher, listener) = + crate::linux::workload_launcher::start().expect("start workload launcher"); let _broker = NetworkBroker::start_for_test(listener).expect("start network broker"); let error = launcher .execute(move || -> io::Result<()> { @@ -2429,8 +2414,8 @@ mod tests { fn broker_refuses_socket_families_it_cannot_confine() { // Independent of the static workload filter: the broker continues // only Unix and netlink sockets and creates INET sockets itself. - let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() - .expect("start workload launcher"); + let (launcher, listener) = + crate::linux::workload_launcher::start().expect("start workload launcher"); let _broker = NetworkBroker::start_for_test(listener).expect("start network broker"); let results = launcher .execute(|| { @@ -2513,8 +2498,8 @@ mod tests { // address succeeds, unlike a native EINVAL, so a restart is safe. let service = TcpListener::bind("127.0.0.1:0").unwrap(); let address = service.local_addr().unwrap(); - let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() - .expect("start workload launcher"); + let (launcher, listener) = + crate::linux::workload_launcher::start().expect("start workload launcher"); let _broker = NetworkBroker::start_for_test(listener).expect("start network broker"); let (bind_again, connect_again) = launcher .execute(move || -> io::Result<(io::Result<()>, Option)> { @@ -2565,10 +2550,10 @@ mod tests { println!("kill={kill} tgkill={tgkill}"); return; } - let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() - .expect("start workload launcher"); + let (launcher, listener) = + crate::linux::workload_launcher::start().expect("start workload launcher"); let broker = NetworkBroker::start_for_test(listener).expect("start network broker"); - let run_workload = |launcher: &openshell_isolation_interface::linux::workload_launcher::WorkloadLauncher| { + let run_workload = |launcher: &crate::linux::workload_launcher::WorkloadLauncher| { let output = launcher .execute(|| { std::process::Command::new(std::env::current_exe().unwrap()) @@ -2610,8 +2595,8 @@ mod tests { .unwrap(); saturated.listen(0).unwrap(); let address = saturated.local_addr().unwrap().as_socket().unwrap(); - let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() - .expect("start workload launcher"); + let (launcher, listener) = + crate::linux::workload_launcher::start().expect("start workload launcher"); let _broker = NetworkBroker::start_for_test(listener).expect("start network broker"); let (blocking_wait, nonblocking_result) = launcher .execute(move || { @@ -2652,8 +2637,8 @@ mod tests { // Workload sockets can only present loopback source addresses. A // non-loopback peer on a loopback-bound workload listener is therefore // a non-workload process in the same network namespace. - let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() - .expect("start workload launcher"); + let (launcher, listener) = + crate::linux::workload_launcher::start().expect("start workload launcher"); let _broker = NetworkBroker::start_for_test(listener).expect("start network broker"); let errors = launcher .execute(|| { @@ -2697,8 +2682,8 @@ mod tests { #[test] fn external_connect_waits_for_explicit_relay_decision() { - let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() - .expect("start workload launcher"); + let (launcher, listener) = + crate::linux::workload_launcher::start().expect("start workload launcher"); let broker = NetworkBroker::start_for_test(listener).expect("start network broker"); let client = std::thread::spawn(move || { launcher @@ -2737,8 +2722,8 @@ mod tests { #[test] fn denied_external_connect_keeps_socket_unconnected() { - let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() - .expect("start workload launcher"); + let (launcher, listener) = + crate::linux::workload_launcher::start().expect("start workload launcher"); let broker = NetworkBroker::start_for_test(listener).expect("start network broker"); let client = std::thread::spawn(move || { launcher @@ -2768,8 +2753,8 @@ mod tests { #[test] fn udp_dns_normalizes_wildcard_source_for_relay_attribution() { - let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() - .expect("start workload launcher"); + let (launcher, listener) = + crate::linux::workload_launcher::start().expect("start workload launcher"); let broker = NetworkBroker::start_for_test(listener).expect("start network broker"); let dns_address = broker.dns_address(); let client = std::thread::spawn(move || { @@ -2808,8 +2793,8 @@ mod tests { fn udp_dns_after_connect_sends_with_sendmmsg() { // glibc connects the resolver socket to the nameserver, then sends A // and AAAA together with sendmmsg and no destination. - let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() - .expect("start workload launcher"); + let (launcher, listener) = + crate::linux::workload_launcher::start().expect("start workload launcher"); let broker = NetworkBroker::start_for_test(listener).expect("start network broker"); let dns_address = broker.dns_address(); let client = std::thread::spawn(move || { @@ -2874,8 +2859,8 @@ mod tests { // Ancillary data can carry a per-message routing override such as // IP_PKTINFO. The kernel performs mediated DNS sends, so control data // is refused. - let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() - .expect("start workload launcher"); + let (launcher, listener) = + crate::linux::workload_launcher::start().expect("start workload launcher"); let broker = NetworkBroker::start_for_test(listener).expect("start network broker"); let dns_address = broker.dns_address(); let errno = launcher @@ -2910,8 +2895,8 @@ mod tests { #[test] fn udp_dns_allows_repeated_destination_sends_to_the_pinned_relay() { - let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() - .expect("start workload launcher"); + let (launcher, listener) = + crate::linux::workload_launcher::start().expect("start workload launcher"); let broker = NetworkBroker::start_for_test(listener).expect("start network broker"); let dns_address = broker.dns_address(); let client = std::thread::spawn(move || { @@ -2961,8 +2946,8 @@ mod tests { #[test] fn udp_port_zero_route_probes_are_local_and_reusable() { - let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() - .expect("start workload launcher"); + let (launcher, listener) = + crate::linux::workload_launcher::start().expect("start workload launcher"); let _broker = NetworkBroker::start_for_test(listener).expect("start network broker"); launcher .execute(|| -> io::Result<()> { @@ -3040,8 +3025,8 @@ mod tests { #[test] fn tcp_dns_preserves_length_framing() { - let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() - .expect("start workload launcher"); + let (launcher, listener) = + crate::linux::workload_launcher::start().expect("start workload launcher"); let broker = NetworkBroker::start_for_test(listener).expect("start network broker"); let dns_address = broker.dns_address(); let client = std::thread::spawn(move || { diff --git a/crates/openshell-sandbox/src/perf.rs b/crates/openshell-sandbox/src/perf.rs index 100eac5edb..408d6d69a1 100644 --- a/crates/openshell-sandbox/src/perf.rs +++ b/crates/openshell-sandbox/src/perf.rs @@ -10,9 +10,9 @@ use std::sync::mpsc; use std::thread; use std::time::{Duration, Instant}; +use crate::linux::workload_launcher; use anyhow::{Context as _, bail}; use clap::ValueEnum; -use openshell_isolation_interface::linux::workload_launcher; use serde::{Deserialize, Serialize}; use socket2::{Domain, Socket, Type}; diff --git a/crates/openshell-sandbox/src/process.rs b/crates/openshell-sandbox/src/process.rs index 20812ce315..1b0f853d23 100644 --- a/crates/openshell-sandbox/src/process.rs +++ b/crates/openshell-sandbox/src/process.rs @@ -20,8 +20,8 @@ use std::os::fd::AsRawFd; use std::path::Path; use std::path::PathBuf; use std::process::Stdio; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, OnceLock}; use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command}; use tracing::debug; @@ -248,7 +248,21 @@ fn apply_canonical_process_environment( } } -fn configured_user_environment() -> HashMap { +static BOUNDARY_USER_ENVIRONMENT: OnceLock> = OnceLock::new(); + +#[cfg(target_os = "linux")] +pub(crate) fn install_boundary_user_environment( + environment: HashMap, +) -> Result<(), String> { + BOUNDARY_USER_ENVIRONMENT + .set(environment) + .map_err(|_| "boundary workload environment already installed".to_string()) +} + +pub(crate) fn configured_user_environment() -> HashMap { + if let Some(environment) = BOUNDARY_USER_ENVIRONMENT.get() { + return environment.clone(); + } std::env::var(openshell_core::sandbox_env::USER_ENVIRONMENT) .ok() .and_then(|json| serde_json::from_str(&json).ok()) @@ -358,7 +372,7 @@ fn parse_pids_max(contents: &str) -> RuntimePidLimitStatus { #[cfg(target_os = "linux")] pub fn spawn_command_with_workload_launcher( - launcher: &openshell_isolation_interface::linux::workload_launcher::WorkloadLauncher, + launcher: &crate::linux::workload_launcher::WorkloadLauncher, mut cmd: Command, ) -> std::io::Result { let runtime = tokio::runtime::Handle::current(); @@ -370,7 +384,7 @@ pub fn spawn_command_with_workload_launcher( #[cfg(target_os = "linux")] pub fn spawn_std_command_with_workload_launcher( - launcher: &openshell_isolation_interface::linux::workload_launcher::WorkloadLauncher, + launcher: &crate::linux::workload_launcher::WorkloadLauncher, mut cmd: std::process::Command, ) -> std::io::Result { launcher.execute(move || cmd.spawn())? @@ -407,7 +421,7 @@ impl ProcessHandle { #[cfg(target_os = "linux")] #[allow(clippy::too_many_arguments)] pub fn spawn( - launcher: &openshell_isolation_interface::linux::workload_launcher::WorkloadLauncher, + launcher: &crate::linux::workload_launcher::WorkloadLauncher, program: &str, args: &[String], workspace: &ResolvedWorkspace, @@ -458,7 +472,7 @@ impl ProcessHandle { #[cfg(target_os = "linux")] #[allow(clippy::too_many_arguments)] fn spawn_impl( - launcher: &openshell_isolation_interface::linux::workload_launcher::WorkloadLauncher, + launcher: &crate::linux::workload_launcher::WorkloadLauncher, program: &str, args: &[String], workspace: &ResolvedWorkspace, @@ -534,11 +548,8 @@ impl ProcessHandle { let prepared_sandbox = prepare_child_sandbox(policy, workspace.root(), &runtime_read_only) .map_err(|err| miette::miette!("Failed to prepare sandbox: {err}"))?; #[cfg(target_os = "linux")] - let mut child_hardening = - openshell_isolation_interface::linux::child_seccomp::prepare(std::process::id()) - .map_err(|error| { - miette::miette!("prepare child self-protection filter: {error}") - })?; + let mut child_hardening = crate::linux::child_seccomp::prepare(std::process::id()) + .map_err(|error| miette::miette!("prepare child self-protection filter: {error}"))?; // Set up process group for signal handling (non-interactive mode only). // In interactive mode, we inherit the parent's process group to maintain // proper terminal control for shells and interactive programs. @@ -1260,38 +1271,33 @@ mod tests { fn probe_hardened_child(probe: unsafe fn() -> i64) -> i64 { const HARDEN_FAILED: i64 = -2; - let mut fds = [0; 2]; - let pipe_rc = unsafe { libc::pipe(fds.as_mut_ptr()) }; - assert_eq!( - pipe_rc, - 0, - "pipe failed: {}", - std::io::Error::last_os_error() - ); + let (read_fd, write_fd) = nix::unistd::pipe().expect("create probe pipe"); match unsafe { fork() }.expect("fork should succeed") { ForkResult::Child => { - unsafe { libc::close(fds[0]) }; + drop(read_fd); let value = match harden_child_process() { Ok(()) => unsafe { probe() }, Err(_) => HARDEN_FAILED, }; let bytes = value.to_ne_bytes(); - let written = unsafe { libc::write(fds[1], bytes.as_ptr().cast(), bytes.len()) }; + let written = rustix::io::write(&write_fd, &bytes); + drop(write_fd); + // SAFETY: terminate the fork child without running inherited + // parent destructors; the write wrapper performs one syscall. unsafe { - libc::close(fds[1]); - libc::_exit(i32::from(written != bytes.len().cast_signed())); + libc::_exit(i32::from(written != Ok(bytes.len()))); } } ForkResult::Parent { child } => { - unsafe { libc::close(fds[1]) }; + drop(write_fd); let mut bytes = [0u8; size_of::()]; - let read = unsafe { libc::read(fds[0], bytes.as_mut_ptr().cast(), bytes.len()) }; - unsafe { libc::close(fds[0]) }; + let read = rustix::io::read(&read_fd, &mut bytes[..]); + drop(read_fd); assert_eq!( - read.cast_unsigned(), - bytes.len(), - "expected {} probe bytes, got {}", + read, + Ok(bytes.len()), + "expected {} probe bytes, got {:?}", bytes.len(), read ); @@ -1307,15 +1313,11 @@ mod tests { } #[cfg(unix)] - #[allow(unsafe_code)] - unsafe fn core_dump_limit_is_zero_probe() -> i64 { - let mut limit = std::mem::MaybeUninit::::uninit(); - let rc = unsafe { libc::getrlimit(libc::RLIMIT_CORE, limit.as_mut_ptr()) }; - if rc != 0 { - return -1; + fn core_dump_limit_is_zero_probe() -> i64 { + match nix::sys::resource::getrlimit(nix::sys::resource::Resource::RLIMIT_CORE) { + Ok((current, maximum)) => i64::from(current == 0 && maximum == 0), + Err(_) => -1, } - let limit = unsafe { limit.assume_init() }; - i64::from(limit.rlim_cur == 0 && limit.rlim_max == 0) } #[test] diff --git a/crates/openshell-sandbox/src/provider_files.rs b/crates/openshell-sandbox/src/provider_files.rs index 384eb6b31f..e7687ff379 100644 --- a/crates/openshell-sandbox/src/provider_files.rs +++ b/crates/openshell-sandbox/src/provider_files.rs @@ -3,18 +3,15 @@ //! Read-only provider files served on demand through seccomp FD injection. -#![allow(unsafe_code)] - use std::collections::HashMap; -use std::ffi::CString; use std::fs::{File, Permissions}; use std::io::{self, Seek as _, SeekFrom, Write as _}; -use std::os::fd::{AsRawFd as _, FromRawFd as _}; +use std::os::fd::AsRawFd as _; use std::os::unix::fs::PermissionsExt as _; use std::sync::{Arc, RwLock}; -use openshell_isolation_interface::linux::seccomp_notify::{Notification, NotificationListener}; -use openshell_isolation_interface::linux::task_memory; +use crate::linux::seccomp_notify::{Notification, NotificationListener}; +use crate::linux::task_memory; const PREFIX: &str = "/run/openshell/providers/"; const PROC_PREFIX: &str = "/proc/"; @@ -300,22 +297,22 @@ fn open_flags(notification: &Notification) -> io::Result { } fn sealed_memfd(content: &[u8]) -> io::Result { - let name = CString::new("openshell-provider").expect("static name"); - let fd = - unsafe { libc::memfd_create(name.as_ptr(), libc::MFD_CLOEXEC | libc::MFD_ALLOW_SEALING) }; - if fd < 0 { - return Err(io::Error::last_os_error()); - } - let mut file = unsafe { File::from_raw_fd(fd) }; - // memfd_create defaults to 0777. Keep the metadata private as well as the - // returned descriptor read-only, since workloads may inspect it with fstat. + let fd = rustix::fs::memfd_create( + "openshell-provider", + rustix::fs::MemfdFlags::CLOEXEC | rustix::fs::MemfdFlags::ALLOW_SEALING, + )?; + let mut file = File::from(fd); + // Keep metadata private as well as the returned descriptor read-only. file.set_permissions(Permissions::from_mode(0o600))?; file.write_all(content)?; file.seek(SeekFrom::Start(0))?; - let seals = libc::F_SEAL_SEAL | libc::F_SEAL_WRITE | libc::F_SEAL_GROW | libc::F_SEAL_SHRINK; - if unsafe { libc::fcntl(file.as_raw_fd(), libc::F_ADD_SEALS, seals) } < 0 { - return Err(io::Error::last_os_error()); - } + rustix::fs::fcntl_add_seals( + &file, + rustix::fs::SealFlags::SEAL + | rustix::fs::SealFlags::WRITE + | rustix::fs::SealFlags::GROW + | rustix::fs::SealFlags::SHRINK, + )?; // memfd_create returns O_RDWR. Reopen the sealed object through our own // procfs descriptor so the child receives an actual O_RDONLY description. File::open(format!("/proc/self/fd/{}", file.as_raw_fd())) @@ -326,7 +323,6 @@ mod tests { use super::{ProviderFiles, comm_target, sealed_memfd}; use std::collections::HashMap; use std::io::Read as _; - use std::os::fd::AsRawFd as _; use std::os::unix::fs::PermissionsExt as _; #[test] @@ -370,8 +366,11 @@ mod tests { fn memfd_is_read_only_and_positioned_at_start() { let mut file = sealed_memfd(b"version = 1\n").unwrap(); assert_eq!(file.metadata().unwrap().permissions().mode() & 0o777, 0o600); - let flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFL) }; - assert_eq!(flags & libc::O_ACCMODE, libc::O_RDONLY); + let flags = rustix::fs::fcntl_getfl(&file).unwrap(); + assert_eq!( + flags & rustix::fs::OFlags::ACCMODE, + rustix::fs::OFlags::RDONLY + ); let mut read = String::new(); file.read_to_string(&mut read).unwrap(); assert_eq!(read, "version = 1\n"); diff --git a/crates/openshell-sandbox/src/pty.rs b/crates/openshell-sandbox/src/pty.rs index d887342306..003bfc62cf 100644 --- a/crates/openshell-sandbox/src/pty.rs +++ b/crates/openshell-sandbox/src/pty.rs @@ -3,6 +3,7 @@ //! Workload-side PTY and audited pre-exec setup. +use std::os::fd::AsFd; use std::os::fd::RawFd; use std::process::Command; @@ -12,13 +13,16 @@ use openshell_core::policy::SandboxPolicy; #[cfg(unix)] use std::os::unix::process::CommandExt as _; -#[allow(unsafe_code)] -pub fn set_winsize(fd: RawFd, winsize: Winsize) -> std::io::Result<()> { - // SAFETY: fd is the owned PTY master and winsize is initialized. - let rc = unsafe { libc::ioctl(fd, libc::TIOCSWINSZ, &winsize) }; - if rc != 0 { - return Err(std::io::Error::last_os_error()); - } +pub fn set_winsize(fd: impl AsFd, winsize: Winsize) -> std::io::Result<()> { + rustix::termios::tcsetwinsize( + fd, + rustix::termios::Winsize { + ws_row: winsize.ws_row, + ws_col: winsize.ws_col, + ws_xpixel: winsize.ws_xpixel, + ws_ypixel: winsize.ws_ypixel, + }, + )?; Ok(()) } @@ -57,8 +61,7 @@ pub fn install_pre_exec( _workdir: Option, slave_fd: RawFd, #[cfg(target_os = "linux")] prepared: Option, - #[cfg(target_os = "linux")] - child_hardening: openshell_isolation_interface::linux::child_seccomp::ChildHardeningProgram, + #[cfg(target_os = "linux")] child_hardening: crate::linux::child_seccomp::ChildHardeningProgram, ) -> anyhow::Result<()> { #[cfg(target_os = "linux")] let mut prepared = prepared; @@ -92,8 +95,7 @@ pub fn install_pre_exec_no_pty( policy: SandboxPolicy, _workdir: Option, #[cfg(target_os = "linux")] prepared: Option, - #[cfg(target_os = "linux")] - child_hardening: openshell_isolation_interface::linux::child_seccomp::ChildHardeningProgram, + #[cfg(target_os = "linux")] child_hardening: crate::linux::child_seccomp::ChildHardeningProgram, ) -> anyhow::Result<()> { #[cfg(target_os = "linux")] let mut prepared = prepared; @@ -122,7 +124,7 @@ fn enter_sandbox( policy: &SandboxPolicy, #[cfg(target_os = "linux")] prepared: Option, #[cfg(target_os = "linux")] - child_hardening: &mut openshell_isolation_interface::linux::child_seccomp::ChildHardeningProgram, + child_hardening: &mut crate::linux::child_seccomp::ChildHardeningProgram, ) -> std::io::Result<()> { crate::process::harden_child_process() .map_err(|error| std::io::Error::other(error.to_string()))?; diff --git a/crates/openshell-sandbox/src/sandbox/linux/mod.rs b/crates/openshell-sandbox/src/sandbox/linux/mod.rs index acfd46edc5..a6f5817da7 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/mod.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/mod.rs @@ -86,7 +86,7 @@ pub fn enforce(prepared: PreparedSandbox) -> Result<()> { /// installation, so this order is mandatory for capability-free children. pub fn enforce_capability_free( prepared: PreparedSandbox, - child_hardening: &mut openshell_isolation_interface::linux::child_seccomp::ChildHardeningProgram, + child_hardening: &mut crate::linux::child_seccomp::ChildHardeningProgram, ) -> Result<()> { for ruleset in prepared.landlock { landlock::enforce(ruleset)?; diff --git a/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs b/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs index e9fa55cc1b..d180920325 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs @@ -710,7 +710,7 @@ mod tests { let mut status: libc::c_int = 0; unsafe { libc::waitpid(pid, &mut status, 0) }; assert!( - unsafe { libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 }, + libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0, "mount should be blocked by the supervisor prelude filter" ); } @@ -741,7 +741,7 @@ mod tests { let mut status: libc::c_int = 0; unsafe { libc::waitpid(pid, &mut status, 0) }; assert!( - unsafe { libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 }, + libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0, "clone3 should be blocked with ENOSYS, not EPERM" ); } @@ -787,7 +787,7 @@ mod tests { let mut status: libc::c_int = 0; unsafe { libc::waitpid(pid, &mut status, 0) }; assert!( - unsafe { libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 }, + libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0, "additional seccomp filter installation should be blocked after startup" ); } @@ -821,7 +821,7 @@ mod tests { let mut status: libc::c_int = 0; unsafe { libc::waitpid(pid, &mut status, 0) }; assert!( - unsafe { libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 }, + libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0, "socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE) should be allowed for getifaddrs(3)" ); } @@ -858,7 +858,7 @@ mod tests { let mut status: libc::c_int = 0; unsafe { libc::waitpid(pid, &mut status, 0) }; assert!( - unsafe { libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 }, + libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0, "socket(AF_NETLINK, SOCK_RAW, NETLINK_SOCK_DIAG) should be blocked with EPERM" ); } @@ -969,7 +969,7 @@ mod tests { let mut status: libc::c_int = 0; unsafe { libc::waitpid(pid, &mut status, 0) }; assert!( - unsafe { libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 }, + libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0, "block mode must deny IPv4, IPv6, and packet sockets while retaining Unix IPC" ); } diff --git a/crates/openshell-server/build.rs b/crates/openshell-server/build.rs index b7999d28b9..1ddffb2a48 100644 --- a/crates/openshell-server/build.rs +++ b/crates/openshell-server/build.rs @@ -5,6 +5,19 @@ use std::env; use std::path::PathBuf; fn main() -> Result<(), Box> { + // Preserve the vendored include-tree guarantee even when the caller has + // PROTOC_INCLUDE set: prost validates that ambient path independently of + // explicit includes. Re-enter with a child-only environment override. + if env::var_os("PROTOC_INCLUDE").is_some() { + let status = std::process::Command::new(env::current_exe()?) + .env_remove("PROTOC_INCLUDE") + .status()?; + if !status.success() { + return Err(format!("vendored protobuf build failed: {status}").into()); + } + return Ok(()); + } + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?); let storage_proto_dir = manifest_dir.join("proto"); let public_proto_dir = manifest_dir.join("../../proto"); @@ -23,12 +36,11 @@ fn main() -> Result<(), Box> { ); } - // SAFETY: Build scripts run in their own single-threaded process. - #[allow(unsafe_code)] - unsafe { - env::set_var("PROTOC", protoc_bin_vendored::protoc_bin_path()?); - env::set_var("PROTOC_INCLUDE", protoc_bin_vendored::include_path()?); - } + // Configure the vendored compiler and well-known includes without changing + // the build process environment. + let mut proto_config = tonic_prost_build::Config::new(); + proto_config.protoc_executable(protoc_bin_vendored::protoc_bin_path()?); + let proto_include = protoc_bin_vendored::include_path()?; let descriptor_path = PathBuf::from(env::var("OUT_DIR")?).join("storage_descriptor.bin"); tonic_prost_build::configure() @@ -44,7 +56,11 @@ fn main() -> Result<(), Box> { "::openshell_core::proto::sandbox::v1", ) .file_descriptor_set_path(&descriptor_path) - .compile_protos(&[storage_proto], &[storage_proto_dir, public_proto_dir])?; + .compile_with_config( + proto_config, + &[storage_proto], + &[storage_proto_dir, public_proto_dir, proto_include], + )?; Ok(()) } diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 001127761e..10865192a1 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -1336,7 +1336,7 @@ fn resolve_mtls_auth_enabled( #[cfg(test)] mod tests { use super::{Cli, command}; - use crate::TEST_ENV_LOCK as ENV_LOCK; + use crate::test_environment::Environment; use clap::Parser; use std::net::{IpAddr, Ipv4Addr}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -1439,41 +1439,6 @@ mod tests { registry } - struct EnvVarGuard { - key: &'static str, - original: Option, - } - - impl EnvVarGuard { - #[allow(unsafe_code)] - fn set(key: &'static str, value: &str) -> Self { - let original = std::env::var(key).ok(); - // SAFETY: tests serialize environment mutation with ENV_LOCK. - unsafe { std::env::set_var(key, value) }; - Self { key, original } - } - - #[allow(unsafe_code)] - fn remove(key: &'static str) -> Self { - let original = std::env::var(key).ok(); - // SAFETY: tests serialize environment mutation with ENV_LOCK. - unsafe { std::env::remove_var(key) }; - Self { key, original } - } - } - - impl Drop for EnvVarGuard { - #[allow(unsafe_code)] - fn drop(&mut self) { - match self.original.as_deref() { - // SAFETY: tests serialize environment mutation with ENV_LOCK. - Some(value) => unsafe { std::env::set_var(self.key, value) }, - // SAFETY: tests serialize environment mutation with ENV_LOCK. - None => unsafe { std::env::remove_var(self.key) }, - } - } - } - #[test] fn command_uses_gateway_binary_name() { let mut help = Vec::new(); @@ -1491,179 +1456,168 @@ mod tests { #[test] fn command_defaults_bind_address_to_loopback() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _guard = EnvVarGuard::remove("OPENSHELL_BIND_ADDRESS"); - let cli = - Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]).unwrap(); - assert_eq!(cli.run.bind_address, IpAddr::V4(Ipv4Addr::LOCALHOST)); + Environment::new().remove("OPENSHELL_BIND_ADDRESS").run(|| { + let cli = + Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]).unwrap(); + assert_eq!(cli.run.bind_address, IpAddr::V4(Ipv4Addr::LOCALHOST)); + }); } #[test] fn command_parses_bind_address() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _guard = EnvVarGuard::remove("OPENSHELL_BIND_ADDRESS"); - let cli = Cli::try_parse_from([ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--bind-address", - "127.0.0.1", - ]) - .unwrap(); - assert_eq!(cli.run.bind_address, IpAddr::V4(Ipv4Addr::LOCALHOST)); + Environment::new().remove("OPENSHELL_BIND_ADDRESS").run(|| { + let cli = Cli::try_parse_from([ + "openshell-gateway", + "--db-url", + "sqlite::memory:", + "--bind-address", + "127.0.0.1", + ]) + .unwrap(); + assert_eq!(cli.run.bind_address, IpAddr::V4(Ipv4Addr::LOCALHOST)); + }); } #[test] fn command_reads_bind_address_from_env() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _guard = EnvVarGuard::set("OPENSHELL_BIND_ADDRESS", "0.0.0.0"); + Environment::new() + .set("OPENSHELL_BIND_ADDRESS", "0.0.0.0") + .run(|| { + let cli = Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]) + .expect("env should provide bind address"); - let cli = Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]) - .expect("env should provide bind address"); - - assert_eq!(cli.run.bind_address, IpAddr::V4(Ipv4Addr::UNSPECIFIED)); + assert_eq!(cli.run.bind_address, IpAddr::V4(Ipv4Addr::UNSPECIFIED)); + }); } #[test] fn command_enables_loopback_service_http_by_default() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _guard = EnvVarGuard::remove("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP"); - - let cli = - Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]).unwrap(); + Environment::new() + .remove("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP") + .run(|| { + let cli = Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]) + .unwrap(); - assert!(cli.run.enable_loopback_service_http); + assert!(cli.run.enable_loopback_service_http); + }); } #[test] fn websocket_tunnel_is_disabled_by_default_and_can_be_enabled_from_file() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _guard = EnvVarGuard::remove("OPENSHELL_ENABLE_WEBSOCKET_TUNNEL"); - let (mut args, matches) = - parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); - assert!(!args.enable_websocket_tunnel); - - let file = config_file_from_toml("[openshell.gateway]\nenable_websocket_tunnel = true\n"); - merge_file_into_args(&mut args, &file.openshell.gateway, &matches); - assert!(args.enable_websocket_tunnel); - - let (mut args, matches) = parse_with_args(&[ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--enable-websocket-tunnel=false", - ]); - merge_file_into_args(&mut args, &file.openshell.gateway, &matches); - assert!(!args.enable_websocket_tunnel, "CLI flag must override file"); + Environment::new() + .remove("OPENSHELL_ENABLE_WEBSOCKET_TUNNEL") + .run(|| { + let (mut args, matches) = + parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); + assert!(!args.enable_websocket_tunnel); + + let file = + config_file_from_toml("[openshell.gateway]\nenable_websocket_tunnel = true\n"); + merge_file_into_args(&mut args, &file.openshell.gateway, &matches); + assert!(args.enable_websocket_tunnel); + + let (mut args, matches) = parse_with_args(&[ + "openshell-gateway", + "--db-url", + "sqlite::memory:", + "--enable-websocket-tunnel=false", + ]); + merge_file_into_args(&mut args, &file.openshell.gateway, &matches); + assert!(!args.enable_websocket_tunnel, "CLI flag must override file"); + }); } #[test] fn command_disables_loopback_service_http_with_false_value() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _guard = EnvVarGuard::remove("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP"); - - let cli = Cli::try_parse_from([ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--enable-loopback-service-http=false", - ]) - .unwrap(); + Environment::new() + .remove("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP") + .run(|| { + let cli = Cli::try_parse_from([ + "openshell-gateway", + "--db-url", + "sqlite::memory:", + "--enable-loopback-service-http=false", + ]) + .unwrap(); - assert!(!cli.run.enable_loopback_service_http); + assert!(!cli.run.enable_loopback_service_http); + }); } #[test] fn command_reads_loopback_service_http_from_env() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _guard = EnvVarGuard::set("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP", "false"); - - let cli = - Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]).unwrap(); + Environment::new() + .set("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP", "false") + .run(|| { + let cli = Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]) + .unwrap(); - assert!(!cli.run.enable_loopback_service_http); + assert!(!cli.run.enable_loopback_service_http); + }); } #[test] fn command_parses_oidc_insecure_http_acknowledgement_value() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _guard = EnvVarGuard::remove("OPENSHELL_OIDC_DANGEROUSLY_ALLOW_INSECURE_HTTP"); - - let cli = Cli::try_parse_from([ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--oidc-dangerously-allow-insecure-http", - "true", - ]) - .expect("launcher-style boolean flag and value should parse"); + Environment::new() + .remove("OPENSHELL_OIDC_DANGEROUSLY_ALLOW_INSECURE_HTTP") + .run(|| { + let cli = Cli::try_parse_from([ + "openshell-gateway", + "--db-url", + "sqlite::memory:", + "--oidc-dangerously-allow-insecure-http", + "true", + ]) + .expect("launcher-style boolean flag and value should parse"); - assert!(cli.run.oidc_dangerously_allow_insecure_http); + assert!(cli.run.oidc_dangerously_allow_insecure_http); + }); } #[test] fn command_reads_server_san_from_env() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _guard = EnvVarGuard::set("OPENSHELL_SERVER_SAN", "*.apps.example.com"); + Environment::new() + .set("OPENSHELL_SERVER_SAN", "*.apps.example.com") + .run(|| { + let cli = Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]) + .unwrap(); - let cli = - Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]).unwrap(); - - assert_eq!(cli.run.server_sans, vec!["*.apps.example.com".to_string()]); + assert_eq!(cli.run.server_sans, vec!["*.apps.example.com".to_string()]); + }); } #[test] fn command_reads_mtls_auth_from_env() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _guard = EnvVarGuard::set("OPENSHELL_ENABLE_MTLS_AUTH", "true"); - - let cli = - Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]).unwrap(); + Environment::new() + .set("OPENSHELL_ENABLE_MTLS_AUTH", "true") + .run(|| { + let cli = Cli::try_parse_from(["openshell-gateway", "--db-url", "sqlite::memory:"]) + .unwrap(); - assert!(cli.run.enable_mtls_auth); + assert!(cli.run.enable_mtls_auth); + }); } #[test] fn command_parses_grpc_rate_limit_flags() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _g1 = EnvVarGuard::remove("OPENSHELL_GRPC_RATE_LIMIT_REQUESTS"); - let _g2 = EnvVarGuard::remove("OPENSHELL_GRPC_RATE_LIMIT_WINDOW_SECONDS"); - - let cli = Cli::try_parse_from([ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--grpc-rate-limit-requests", - "120", - "--grpc-rate-limit-window-seconds", - "60", - ]) - .unwrap(); + Environment::new() + .remove("OPENSHELL_GRPC_RATE_LIMIT_REQUESTS") + .remove("OPENSHELL_GRPC_RATE_LIMIT_WINDOW_SECONDS") + .run(|| { + let cli = Cli::try_parse_from([ + "openshell-gateway", + "--db-url", + "sqlite::memory:", + "--grpc-rate-limit-requests", + "120", + "--grpc-rate-limit-window-seconds", + "60", + ]) + .unwrap(); - assert_eq!(cli.run.grpc_rate_limit_requests, Some(120)); - assert_eq!(cli.run.grpc_rate_limit_window_seconds, Some(60)); + assert_eq!(cli.run.grpc_rate_limit_requests, Some(120)); + assert_eq!(cli.run.grpc_rate_limit_window_seconds, Some(60)); + }); } #[test] @@ -1711,119 +1665,127 @@ mod tests { #[test] fn legacy_compute_driver_environment_accepts_one_normalized_name() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); - let _legacy = EnvVarGuard::set("OPENSHELL_DRIVERS", " PodMan "); - let (mut args, _) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); - - assert!(super::resolve_legacy_driver_selector_env(&mut args).unwrap()); - assert_eq!(args.compute_driver.as_deref(), Some("podman")); + Environment::new() + .remove("OPENSHELL_COMPUTE_DRIVER") + .set("OPENSHELL_DRIVERS", " PodMan ") + .run(|| { + let (mut args, _) = + parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); + + assert!(super::resolve_legacy_driver_selector_env(&mut args).unwrap()); + assert_eq!(args.compute_driver.as_deref(), Some("podman")); + }); } #[test] fn legacy_compute_driver_environment_flows_through_server_preparation() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); - let _config = EnvVarGuard::set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()); - let _config_path = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); - let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); - let _legacy = EnvVarGuard::set("OPENSHELL_DRIVERS", "podman"); - let (mut args, matches) = parse_with_args(&[ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--disable-tls", - ]); - let registry = test_registry("podman", true); - let prepared = - super::prepare_server_config_with_drivers(&mut args, &matches, ®istry).unwrap(); + Environment::new() + .set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()) + .remove("OPENSHELL_GATEWAY_CONFIG") + .remove("OPENSHELL_COMPUTE_DRIVER") + .set("OPENSHELL_DRIVERS", "podman") + .run(|| { + let (mut args, matches) = parse_with_args(&[ + "openshell-gateway", + "--db-url", + "sqlite::memory:", + "--disable-tls", + ]); + let registry = test_registry("podman", true); + + let prepared = + super::prepare_server_config_with_drivers(&mut args, &matches, ®istry) + .unwrap(); - assert_eq!(prepared.compute_driver.name(), "podman"); - assert!(prepared.legacy_compute_driver_env_seen); + assert_eq!(prepared.compute_driver.name(), "podman"); + assert!(prepared.legacy_compute_driver_env_seen); + }); } #[test] fn legacy_compute_driver_environment_rejects_empty_plural_and_invalid_values() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); - - for value in ["", " ", ",", "podman,", ",podman", "podman,docker"] { - let legacy = EnvVarGuard::set("OPENSHELL_DRIVERS", value); - let (mut args, _) = - parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); - let error = super::resolve_legacy_driver_selector_env(&mut args) - .expect_err("empty and plural legacy selectors must be rejected"); - assert!(error.to_string().contains("exactly one non-empty")); - drop(legacy); + for value in [ + "", + " ", + ",", + "podman,", + ",podman", + "podman,docker", + "podman/path", + ] { + Environment::new() + .remove("OPENSHELL_COMPUTE_DRIVER") + .set("OPENSHELL_DRIVERS", value) + .run(|| { + let value = std::env::var("OPENSHELL_DRIVERS").unwrap(); + let (mut args, _) = + parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); + let error = super::resolve_legacy_driver_selector_env(&mut args) + .expect_err("invalid legacy selector must be rejected"); + if value == "podman/path" { + assert!(error.to_string().contains("invalid compute driver name")); + assert!(!error.to_string().contains("podman/path")); + } else { + assert!(error.to_string().contains("exactly one non-empty")); + } + }); } - - let _legacy = EnvVarGuard::set("OPENSHELL_DRIVERS", "podman/path"); - let (mut args, _) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); - let error = super::resolve_legacy_driver_selector_env(&mut args) - .expect_err("invalid legacy selector must be rejected"); - assert!(error.to_string().contains("invalid compute driver name")); - assert!(!error.to_string().contains("podman/path")); } #[test] fn legacy_compute_driver_environment_allows_equal_canonical_and_rejects_conflict() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); - let _legacy = EnvVarGuard::set("OPENSHELL_DRIVERS", "PODMAN"); - - let (mut equal, _) = parse_with_args(&[ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--compute-driver", - "podman", - ]); - assert!(super::resolve_legacy_driver_selector_env(&mut equal).unwrap()); - assert_eq!(equal.compute_driver.as_deref(), Some("podman")); + Environment::new() + .remove("OPENSHELL_COMPUTE_DRIVER") + .set("OPENSHELL_DRIVERS", "PODMAN") + .run(|| { + let (mut equal, _) = parse_with_args(&[ + "openshell-gateway", + "--db-url", + "sqlite::memory:", + "--compute-driver", + "podman", + ]); + assert!(super::resolve_legacy_driver_selector_env(&mut equal).unwrap()); + assert_eq!(equal.compute_driver.as_deref(), Some("podman")); - let (mut conflict, _) = parse_with_args(&[ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--compute-driver", - "docker", - ]); - let error = super::resolve_legacy_driver_selector_env(&mut conflict) - .expect_err("different canonical and legacy selectors must conflict"); - assert!(error.to_string().contains("conflicts")); - assert!(!error.to_string().contains("podman")); - assert!(!error.to_string().contains("docker")); + let (mut conflict, _) = parse_with_args(&[ + "openshell-gateway", + "--db-url", + "sqlite::memory:", + "--compute-driver", + "docker", + ]); + let error = super::resolve_legacy_driver_selector_env(&mut conflict) + .expect_err("different canonical and legacy selectors must conflict"); + assert!(error.to_string().contains("conflicts")); + assert!(!error.to_string().contains("podman")); + assert!(!error.to_string().contains("docker")); + }); } #[test] fn legacy_compute_driver_environment_supports_remote_driver_socket() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); - let _legacy = EnvVarGuard::set("OPENSHELL_DRIVERS", "kyma"); - let _socket = EnvVarGuard::set( - "OPENSHELL_COMPUTE_DRIVER_SOCKET", - "/run/openshell/kyma.sock", - ); - let (mut args, _) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); - - assert!(super::resolve_legacy_driver_selector_env(&mut args).unwrap()); - super::normalize_compute_driver_socket_args(&mut args).unwrap(); - assert_eq!(args.compute_driver.as_deref(), Some("kyma")); - assert_eq!( - args.compute_driver_socket.as_deref(), - Some(std::path::Path::new("/run/openshell/kyma.sock")) - ); + Environment::new() + .remove("OPENSHELL_COMPUTE_DRIVER") + .set("OPENSHELL_DRIVERS", "kyma") + .set( + "OPENSHELL_COMPUTE_DRIVER_SOCKET", + "/run/openshell/kyma.sock", + ) + .run(|| { + let (mut args, _) = + parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); + + assert!(super::resolve_legacy_driver_selector_env(&mut args).unwrap()); + super::normalize_compute_driver_socket_args(&mut args).unwrap(); + assert_eq!(args.compute_driver.as_deref(), Some("kyma")); + assert_eq!( + args.compute_driver_socket.as_deref(), + Some(std::path::Path::new("/run/openshell/kyma.sock")) + ); + }); } #[test] @@ -1849,106 +1811,102 @@ mod tests { #[test] fn generate_certs_subcommand_parses_without_db_url() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL"); - let _g2 = EnvVarGuard::remove("POD_NAMESPACE"); + Environment::new() + .remove("OPENSHELL_DB_URL") + .remove("POD_NAMESPACE") + .run(|| { + let cli = Cli::try_parse_from([ + "openshell-gateway", + "generate-certs", + "--namespace", + "openshell", + "--server-secret-name", + "openshell-server-tls", + "--client-secret-name", + "openshell-client-tls", + "--jwt-secret-name", + "openshell-jwt-keys", + "--server-san", + "openshell.example.com", + "--server-san", + "10.0.0.1", + ]) + .expect("generate-certs should parse without --db-url"); - let cli = Cli::try_parse_from([ - "openshell-gateway", - "generate-certs", - "--namespace", - "openshell", - "--server-secret-name", - "openshell-server-tls", - "--client-secret-name", - "openshell-client-tls", - "--jwt-secret-name", - "openshell-jwt-keys", - "--server-san", - "openshell.example.com", - "--server-san", - "10.0.0.1", - ]) - .expect("generate-certs should parse without --db-url"); - - assert!(matches!( - cli.command, - Some(super::Commands::GenerateCerts(_)) - )); + assert!(matches!( + cli.command, + Some(super::Commands::GenerateCerts(_)) + )); + }); } #[test] fn generate_certs_local_mode_parses_without_kube_flags() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL"); - let _g2 = EnvVarGuard::remove("POD_NAMESPACE"); + Environment::new() + .remove("OPENSHELL_DB_URL") + .remove("POD_NAMESPACE") + .run(|| { + let cli = Cli::try_parse_from([ + "openshell-gateway", + "generate-certs", + "--output-dir", + "/tmp/openshell-certgen", + ]) + .expect("--output-dir should make namespace/secret-name flags optional"); - let cli = Cli::try_parse_from([ - "openshell-gateway", - "generate-certs", - "--output-dir", - "/tmp/openshell-certgen", - ]) - .expect("--output-dir should make namespace/secret-name flags optional"); - - assert!(matches!( - cli.command, - Some(super::Commands::GenerateCerts(_)) - )); + assert!(matches!( + cli.command, + Some(super::Commands::GenerateCerts(_)) + )); + }); } #[test] fn generate_certs_jwt_only_parses_without_tls_secret_names() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL"); - let _g2 = EnvVarGuard::remove("POD_NAMESPACE"); + Environment::new() + .remove("OPENSHELL_DB_URL") + .remove("POD_NAMESPACE") + .run(|| { + let cli = Cli::try_parse_from([ + "openshell-gateway", + "generate-certs", + "--namespace", + "openshell", + "--jwt-only", + "--jwt-secret-name", + "openshell-jwt-keys", + ]) + .expect("--jwt-only should make TLS secret-name flags optional"); - let cli = Cli::try_parse_from([ - "openshell-gateway", - "generate-certs", - "--namespace", - "openshell", - "--jwt-only", - "--jwt-secret-name", - "openshell-jwt-keys", - ]) - .expect("--jwt-only should make TLS secret-name flags optional"); - - assert!(matches!( - cli.command, - Some(super::Commands::GenerateCerts(_)) - )); + assert!(matches!( + cli.command, + Some(super::Commands::GenerateCerts(_)) + )); + }); } #[test] fn config_preflight_subcommand_parses_without_runtime_requirements() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _db = EnvVarGuard::remove("OPENSHELL_DB_URL"); - let _config = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); + Environment::new() + .remove("OPENSHELL_DB_URL") + .remove("OPENSHELL_GATEWAY_CONFIG") + .run(|| { + let cli = Cli::try_parse_from([ + "openshell-gateway", + "config", + "preflight", + "--path", + "/tmp/gateway.toml", + ]) + .expect("config preflight should parse without runtime arguments"); - let cli = Cli::try_parse_from([ - "openshell-gateway", - "config", - "preflight", - "--path", - "/tmp/gateway.toml", - ]) - .expect("config preflight should parse without runtime arguments"); - - assert!(matches!( - cli.command, - Some(super::Commands::Config(super::ConfigArgs { - command: super::ConfigCommand::Preflight(_) - })) - )); + assert!(matches!( + cli.command, + Some(super::Commands::Config(super::ConfigArgs { + command: super::ConfigCommand::Preflight(_) + })) + )); + }); } #[test] @@ -1969,158 +1927,156 @@ mod tests { #[test] fn config_preflight_replay_validates_effective_daemon_flags() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); - let _config_home = - EnvVarGuard::set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()); - let _config = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); - let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); - let _requests = EnvVarGuard::remove("OPENSHELL_GRPC_RATE_LIMIT_REQUESTS"); - let _window = EnvVarGuard::remove("OPENSHELL_GRPC_RATE_LIMIT_WINDOW_SECONDS"); - let (run, matches) = parse_with_args(&["openshell-gateway"]); - - let error = super::run_config_preflight( - super::ConfigPreflightArgs { - gateway_args: ["--grpc-rate-limit-requests", "10"] - .map(std::ffi::OsString::from) - .to_vec(), - ..Default::default() - }, - run.clone(), - &matches, - ) - .expect_err("unpaired replayed rate limit must fail preflight"); - assert!(error.to_string().contains("requires both")); - super::run_config_preflight( - super::ConfigPreflightArgs { - gateway_args: [ - "--grpc-rate-limit-requests", - "10", - "--grpc-rate-limit-window-seconds", - "60", - ] - .map(std::ffi::OsString::from) - .to_vec(), - ..Default::default() - }, - run, - &matches, - ) - .expect("paired replayed rate limit must pass preflight"); + Environment::new() + .set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()) + .remove("OPENSHELL_GATEWAY_CONFIG") + .remove("OPENSHELL_DRIVERS") + .remove("OPENSHELL_GRPC_RATE_LIMIT_REQUESTS") + .remove("OPENSHELL_GRPC_RATE_LIMIT_WINDOW_SECONDS") + .run(|| { + let (run, matches) = parse_with_args(&["openshell-gateway"]); + + let error = super::run_config_preflight( + super::ConfigPreflightArgs { + gateway_args: ["--grpc-rate-limit-requests", "10"] + .map(std::ffi::OsString::from) + .to_vec(), + ..Default::default() + }, + run.clone(), + &matches, + ) + .expect_err("unpaired replayed rate limit must fail preflight"); + assert!(error.to_string().contains("requires both")); + + super::run_config_preflight( + super::ConfigPreflightArgs { + gateway_args: [ + "--grpc-rate-limit-requests", + "10", + "--grpc-rate-limit-window-seconds", + "60", + ] + .map(std::ffi::OsString::from) + .to_vec(), + ..Default::default() + }, + run, + &matches, + ) + .expect("paired replayed rate limit must pass preflight"); + }); } #[test] fn config_preflight_matches_driver_selector_and_registry_semantics() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); - let _config_home = - EnvVarGuard::set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()); - let _config_env = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); - let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); - let _legacy = EnvVarGuard::set("OPENSHELL_DRIVERS", "podman,docker"); - let (run, matches) = parse_with_args(&["openshell-gateway"]); - let registry = test_registry("podman", true); - - let error = super::run_config_preflight_with_drivers( - super::ConfigPreflightArgs::default(), - run, - &matches, - ®istry, - ) - .expect_err("plural legacy selector must fail preflight as it fails startup"); - assert!(error.to_string().contains("exactly one non-empty")); + + Environment::new() + .set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()) + .remove("OPENSHELL_GATEWAY_CONFIG") + .remove("OPENSHELL_COMPUTE_DRIVER") + .set("OPENSHELL_DRIVERS", "podman,docker") + .run(|| { + let (run, matches) = parse_with_args(&["openshell-gateway"]); + let registry = test_registry("podman", true); + + let error = super::run_config_preflight_with_drivers( + super::ConfigPreflightArgs::default(), + run, + &matches, + ®istry, + ) + .expect_err("plural legacy selector must fail preflight as it fails startup"); + assert!(error.to_string().contains("exactly one non-empty")); + }); } #[test] fn config_preflight_validates_selected_driver_without_building_it() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); - let _config_home = - EnvVarGuard::set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()); - let _config = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); - let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); - let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); - let (run, matches) = parse_with_args(&[ - "openshell-gateway", - "--compute-driver", - "local", - "--disable-tls", - ]); - let mut registry = crate::ComputeDriverRegistry::new(); - registry - .install( - crate::ComputeDriverRegistration::new( + + Environment::new() + .set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()) + .remove("OPENSHELL_GATEWAY_CONFIG") + .remove("OPENSHELL_COMPUTE_DRIVER") + .remove("OPENSHELL_DRIVERS") + .run(|| { + let (run, matches) = parse_with_args(&[ + "openshell-gateway", + "--compute-driver", "local", - 100, - None, - RejectingValidationFactory, + "--disable-tls", + ]); + let mut registry = crate::ComputeDriverRegistry::new(); + registry + .install( + crate::ComputeDriverRegistration::new( + "local", + 100, + None, + RejectingValidationFactory, + ) + .unwrap(), + ) + .unwrap(); + + REJECTING_VALIDATION_CALLS.store(0, Ordering::SeqCst); + let error = super::run_config_preflight_with_drivers( + super::ConfigPreflightArgs::default(), + run, + &matches, + ®istry, ) - .unwrap(), - ) - .unwrap(); - - REJECTING_VALIDATION_CALLS.store(0, Ordering::SeqCst); - let error = super::run_config_preflight_with_drivers( - super::ConfigPreflightArgs::default(), - run, - &matches, - ®istry, - ) - .expect_err("selected driver validation hook must run"); - assert!(error.to_string().contains("validation hook invoked")); - assert_eq!(REJECTING_VALIDATION_CALLS.load(Ordering::SeqCst), 1); + .expect_err("selected driver validation hook must run"); + assert!(error.to_string().contains("validation hook invoked")); + assert_eq!(REJECTING_VALIDATION_CALLS.load(Ordering::SeqCst), 1); + }); } #[test] fn config_preflight_rejects_factory_without_side_effect_free_validation() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); - let _config_home = - EnvVarGuard::set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()); - let _config = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); - let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); - let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); - let (run, matches) = parse_with_args(&[ - "openshell-gateway", - "--compute-driver", - "legacy", - "--disable-tls", - ]); - let mut registry = crate::ComputeDriverRegistry::new(); - registry - .install( - crate::ComputeDriverRegistration::new("legacy", 100, None, LegacyFactory).unwrap(), - ) - .unwrap(); - let error = super::run_config_preflight_with_drivers( - super::ConfigPreflightArgs::default(), - run, - &matches, - ®istry, - ) - .expect_err("unsupported source-free preflight must fail closed"); - assert!( - error - .to_string() - .contains("does not support side-effect-free") - ); + Environment::new() + .set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()) + .remove("OPENSHELL_GATEWAY_CONFIG") + .remove("OPENSHELL_COMPUTE_DRIVER") + .remove("OPENSHELL_DRIVERS") + .run(|| { + let (run, matches) = parse_with_args(&[ + "openshell-gateway", + "--compute-driver", + "legacy", + "--disable-tls", + ]); + let mut registry = crate::ComputeDriverRegistry::new(); + registry + .install( + crate::ComputeDriverRegistration::new("legacy", 100, None, LegacyFactory) + .unwrap(), + ) + .unwrap(); + + let error = super::run_config_preflight_with_drivers( + super::ConfigPreflightArgs::default(), + run, + &matches, + ®istry, + ) + .expect_err("unsupported source-free preflight must fail closed"); + assert!( + error + .to_string() + .contains("does not support side-effect-free") + ); + }); } #[test] fn config_preflight_validates_configured_auto_detectable_driver_without_probing() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); let config = config_home.path().join("gateway.toml"); std::fs::write( @@ -2128,47 +2084,48 @@ mod tests { "[openshell]\nversion = 2\n[openshell.gateway]\ndisable_tls = true\n[openshell.drivers.local]\n", ) .unwrap(); - let _config_env = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); - let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); - let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); - let (run, matches) = parse_with_args(&["openshell-gateway"]); - let mut registry = crate::ComputeDriverRegistry::new(); - registry - .install( - crate::ComputeDriverRegistration::new( - "local", - 100, - Some(detect_registered_local), - RejectingValidationFactory, - ) - .unwrap(), - ) - .unwrap(); - REGISTRY_DETECTION_CALLS.store(0, Ordering::SeqCst); - REJECTING_VALIDATION_CALLS.store(0, Ordering::SeqCst); - let error = super::run_config_preflight_with_drivers( - super::ConfigPreflightArgs { - path: Some(config), - ..Default::default() - }, - run, - &matches, - ®istry, - ) - .expect_err("auto-detectable driver table validation hook must run"); + Environment::new() + .remove("OPENSHELL_GATEWAY_CONFIG") + .remove("OPENSHELL_COMPUTE_DRIVER") + .remove("OPENSHELL_DRIVERS") + .run(|| { + let (run, matches) = parse_with_args(&["openshell-gateway"]); + let mut registry = crate::ComputeDriverRegistry::new(); + registry + .install( + crate::ComputeDriverRegistration::new( + "local", + 100, + Some(detect_registered_local), + RejectingValidationFactory, + ) + .unwrap(), + ) + .unwrap(); + + REGISTRY_DETECTION_CALLS.store(0, Ordering::SeqCst); + REJECTING_VALIDATION_CALLS.store(0, Ordering::SeqCst); + let error = super::run_config_preflight_with_drivers( + super::ConfigPreflightArgs { + path: Some(config), + ..Default::default() + }, + run, + &matches, + ®istry, + ) + .expect_err("auto-detectable driver table validation hook must run"); - assert!(error.to_string().contains("category=malformed")); - assert!(!error.to_string().contains("validation hook invoked")); - assert_eq!(REGISTRY_DETECTION_CALLS.load(Ordering::SeqCst), 0); - assert_eq!(REJECTING_VALIDATION_CALLS.load(Ordering::SeqCst), 1); + assert!(error.to_string().contains("category=malformed")); + assert!(!error.to_string().contains("validation hook invoked")); + assert_eq!(REGISTRY_DETECTION_CALLS.load(Ordering::SeqCst), 0); + assert_eq!(REJECTING_VALIDATION_CALLS.load(Ordering::SeqCst), 1); + }); } #[test] fn config_preflight_auto_validation_skips_absent_and_opt_in_driver_tables() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); let config = config_home.path().join("gateway.toml"); std::fs::write( @@ -2176,195 +2133,216 @@ mod tests { "[openshell]\nversion = 2\n[openshell.gateway]\ndisable_tls = true\n[openshell.drivers.opt-in]\n", ) .unwrap(); - let _config_env = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); - let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); - let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); - let (run, matches) = parse_with_args(&["openshell-gateway"]); - let mut registry = crate::ComputeDriverRegistry::new(); - for registration in [ - crate::ComputeDriverRegistration::new( - "local", - 100, - Some(detect_registered_local), - RejectingValidationFactory, - ) - .unwrap(), - crate::ComputeDriverRegistration::new("opt-in", 200, None, RejectingValidationFactory) - .unwrap(), - ] { - registry.install(registration).unwrap(); - } - REGISTRY_DETECTION_CALLS.store(0, Ordering::SeqCst); - REJECTING_VALIDATION_CALLS.store(0, Ordering::SeqCst); - super::run_config_preflight_with_drivers( - super::ConfigPreflightArgs { - path: Some(config), - ..Default::default() - }, - run, - &matches, - ®istry, - ) - .expect("unconfigured auto-detectable and configured opt-in drivers must be skipped"); + Environment::new() + .remove("OPENSHELL_GATEWAY_CONFIG") + .remove("OPENSHELL_COMPUTE_DRIVER") + .remove("OPENSHELL_DRIVERS") + .run(|| { + let (run, matches) = parse_with_args(&["openshell-gateway"]); + let mut registry = crate::ComputeDriverRegistry::new(); + for registration in [ + crate::ComputeDriverRegistration::new( + "local", + 100, + Some(detect_registered_local), + RejectingValidationFactory, + ) + .unwrap(), + crate::ComputeDriverRegistration::new( + "opt-in", + 200, + None, + RejectingValidationFactory, + ) + .unwrap(), + ] { + registry.install(registration).unwrap(); + } + + REGISTRY_DETECTION_CALLS.store(0, Ordering::SeqCst); + REJECTING_VALIDATION_CALLS.store(0, Ordering::SeqCst); + super::run_config_preflight_with_drivers( + super::ConfigPreflightArgs { + path: Some(config), + ..Default::default() + }, + run, + &matches, + ®istry, + ) + .expect( + "unconfigured auto-detectable and configured opt-in drivers must be skipped", + ); - assert_eq!(REGISTRY_DETECTION_CALLS.load(Ordering::SeqCst), 0); - assert_eq!(REJECTING_VALIDATION_CALLS.load(Ordering::SeqCst), 0); + assert_eq!(REGISTRY_DETECTION_CALLS.load(Ordering::SeqCst), 0); + assert_eq!(REJECTING_VALIDATION_CALLS.load(Ordering::SeqCst), 0); + }); } #[test] fn config_preflight_allows_driver_independent_mtls() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); - let _config_home = - EnvVarGuard::set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()); - let _config = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); - let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); - let (run, matches) = parse_with_args(&[ - "openshell-gateway", - "--compute-driver", - "shared", - "--tls-cert", - "/tls/server.pem", - "--tls-key", - "/tls/server-key.pem", - "--tls-client-ca", - "/tls/ca.pem", - "--enable-mtls-auth", - "true", - ]); - let registry = test_registry("shared", false); - super::run_config_preflight_with_drivers( - super::ConfigPreflightArgs::default(), - run, - &matches, - ®istry, - ) - .expect("gateway mTLS authentication is independent of the selected driver"); + Environment::new() + .set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()) + .remove("OPENSHELL_GATEWAY_CONFIG") + .remove("OPENSHELL_DRIVERS") + .run(|| { + let (run, matches) = parse_with_args(&[ + "openshell-gateway", + "--compute-driver", + "shared", + "--tls-cert", + "/tls/server.pem", + "--tls-key", + "/tls/server-key.pem", + "--tls-client-ca", + "/tls/ca.pem", + "--enable-mtls-auth", + "true", + ]); + let registry = test_registry("shared", false); + + super::run_config_preflight_with_drivers( + super::ConfigPreflightArgs::default(), + run, + &matches, + ®istry, + ) + .expect("gateway mTLS authentication is independent of the selected driver"); + }); } #[test] fn config_preflight_validates_explicit_remote_driver_endpoint() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); - let _config_home = - EnvVarGuard::set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()); - let _config = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); - let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); - let (run, matches) = parse_with_args(&[ - "openshell-gateway", - "--compute-driver", - "remote", - "--disable-tls", - ]); - let error = - super::run_config_preflight(super::ConfigPreflightArgs::default(), run, &matches) + Environment::new() + .set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()) + .remove("OPENSHELL_GATEWAY_CONFIG") + .remove("OPENSHELL_DRIVERS") + .run(|| { + let (run, matches) = parse_with_args(&[ + "openshell-gateway", + "--compute-driver", + "remote", + "--disable-tls", + ]); + + let error = super::run_config_preflight( + super::ConfigPreflightArgs::default(), + run, + &matches, + ) .expect_err("remote driver without socket_path must fail preflight"); - assert!(error.to_string().contains("requires socket_path")); + assert!(error.to_string().contains("requires socket_path")); + }); } #[test] fn config_preflight_validates_explicit_path_without_creating_state() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); let state_parent = tempfile::tempdir().unwrap(); let state_home = state_parent.path().join("not-created"); let config = config_home.path().join("gateway.toml"); std::fs::write(&config, "[openshell]\nversion = 2\n").unwrap(); - let _config_env = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); - let _state = EnvVarGuard::set("XDG_STATE_HOME", state_home.to_str().unwrap()); - let (run, matches) = parse_with_args(&["openshell-gateway"]); - super::run_config_preflight( - super::ConfigPreflightArgs { - path: Some(config), - ..Default::default() - }, - run, - &matches, - ) - .expect("valid explicit config passes preflight"); + Environment::new() + .remove("OPENSHELL_GATEWAY_CONFIG") + .set("XDG_STATE_HOME", state_home.to_str().unwrap()) + .run(|| { + let (run, matches) = parse_with_args(&["openshell-gateway"]); + + super::run_config_preflight( + super::ConfigPreflightArgs { + path: Some(config), + ..Default::default() + }, + run, + &matches, + ) + .expect("valid explicit config passes preflight"); - assert!( - !state_home.exists(), - "preflight must not create runtime state" - ); + assert!( + !state_home.exists(), + "preflight must not create runtime state" + ); + }); } #[test] fn config_preflight_explicit_path_overrides_environment_selection() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let dir = tempfile::tempdir().unwrap(); let legacy = dir.path().join("legacy.toml"); let current = dir.path().join("current.toml"); std::fs::write(&legacy, "[openshell]\nversion = 1\n").unwrap(); std::fs::write(¤t, "[openshell]\nversion = 2\n").unwrap(); - let _config_env = EnvVarGuard::set("OPENSHELL_GATEWAY_CONFIG", legacy.to_str().unwrap()); - let (run, matches) = parse_with_args(&["openshell-gateway"]); - let error = super::run_config_preflight( - super::ConfigPreflightArgs::default(), - run.clone(), - &matches, - ) - .expect_err("environment-selected legacy config must fail"); - assert!(error.to_string().contains("category=legacy_schema_v1")); + Environment::new() + .set("OPENSHELL_GATEWAY_CONFIG", legacy.to_str().unwrap()) + .run(|| { + let (run, matches) = parse_with_args(&["openshell-gateway"]); - super::run_config_preflight( - super::ConfigPreflightArgs { - path: Some(current), - ..Default::default() - }, - run, - &matches, - ) - .expect("explicit preflight path must override environment selection"); + let error = super::run_config_preflight( + super::ConfigPreflightArgs::default(), + run.clone(), + &matches, + ) + .expect_err("environment-selected legacy config must fail"); + assert!(error.to_string().contains("category=legacy_schema_v1")); + + super::run_config_preflight( + super::ConfigPreflightArgs { + path: Some(current), + ..Default::default() + }, + run, + &matches, + ) + .expect("explicit preflight path must override environment selection"); + }); } #[test] fn config_preflight_allows_absent_auto_discovery_but_rejects_explicit_absence() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); - let _config_env = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); - let _config_home = - EnvVarGuard::set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()); - let (run, matches) = parse_with_args(&["openshell-gateway"]); - super::run_config_preflight(super::ConfigPreflightArgs::default(), run.clone(), &matches) - .expect("absent auto-discovered config is optional"); + Environment::new() + .remove("OPENSHELL_GATEWAY_CONFIG") + .set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()) + .run(|| { + let config_home = + std::path::PathBuf::from(std::env::var_os("XDG_CONFIG_HOME").unwrap()); - let missing = config_home.path().join("missing.toml"); - let error = super::run_config_preflight( - super::ConfigPreflightArgs { - path: Some(missing.clone()), - ..Default::default() - }, - run, - &matches, - ) - .expect_err("explicit missing config must fail"); - assert!(error.to_string().contains("category=missing_path")); - assert!(error.to_string().contains(&missing.display().to_string())); + let (run, matches) = parse_with_args(&["openshell-gateway"]); + + super::run_config_preflight( + super::ConfigPreflightArgs::default(), + run.clone(), + &matches, + ) + .expect("absent auto-discovered config is optional"); + + let missing = config_home.join("missing.toml"); + let error = super::run_config_preflight( + super::ConfigPreflightArgs { + path: Some(missing.clone()), + ..Default::default() + }, + run, + &matches, + ) + .expect_err("explicit missing config must fail"); + assert!(error.to_string().contains("category=missing_path")); + assert!(error.to_string().contains(&missing.display().to_string())); + }); } #[test] fn config_preflight_rejects_effective_semantic_errors() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _config_env = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); + Environment::new().remove("OPENSHELL_GATEWAY_CONFIG").run(|| { + let dir = tempfile::tempdir().unwrap(); let cases = [ ( @@ -2415,14 +2393,11 @@ mod tests { assert!(!format!("{error:?}").contains("secret-")); assert_eq!(std::fs::read(&path).unwrap(), before, "{name}"); } + }); } #[test] fn config_preflight_matches_effective_tls_environment_semantics() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _config_env = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); let dir = tempfile::tempdir().unwrap(); let partial_external = dir.path().join("partial-external.toml"); std::fs::write( @@ -2430,54 +2405,62 @@ mod tests { "[openshell]\nversion = 2\n[openshell.gateway.tls]\ncert_path = '/tls/server.pem'\nkey_path = '/tls/server-key.pem'\nexternal_cert_path = '/tls/external.pem'\nexternal_server_names = ['external.example']\n", ) .unwrap(); - let disable_tls = EnvVarGuard::set("OPENSHELL_DISABLE_TLS", "true"); - let (run, matches) = parse_with_args(&["openshell-gateway"]); - super::run_config_preflight( - super::ConfigPreflightArgs { - path: Some(partial_external), - ..Default::default() - }, - run, - &matches, - ) - .expect("inactive TLS table must not block an effective plaintext gateway"); - drop(disable_tls); + Environment::new() + .remove("OPENSHELL_GATEWAY_CONFIG") + .set("OPENSHELL_DISABLE_TLS", "true") + .run(|| { + let (run, matches) = parse_with_args(&["openshell-gateway"]); + super::run_config_preflight( + super::ConfigPreflightArgs { + path: Some(partial_external), + ..Default::default() + }, + run, + &matches, + ) + .expect("inactive TLS table must not block an effective plaintext gateway"); + }); let config = dir.path().join("client-ca-only.toml"); std::fs::write(&config, "[openshell]\nversion = 2\n").unwrap(); - let _disable_tls = EnvVarGuard::remove("OPENSHELL_DISABLE_TLS"); - let _client_ca = EnvVarGuard::set("OPENSHELL_TLS_CLIENT_CA", "/tls/ca.pem"); - let _cert = EnvVarGuard::remove("OPENSHELL_TLS_CERT"); - let _key = EnvVarGuard::remove("OPENSHELL_TLS_KEY"); - let (run, matches) = parse_with_args(&["openshell-gateway"]); - let error = super::run_config_preflight( - super::ConfigPreflightArgs { - path: Some(config), - ..Default::default() - }, - run, - &matches, - ) - .expect_err("client CA without an explicit server pair must fail before cert generation"); - assert!(error.to_string().contains("category=malformed")); + Environment::new() + .remove("OPENSHELL_GATEWAY_CONFIG") + .remove("OPENSHELL_DISABLE_TLS") + .remove("OPENSHELL_TLS_CERT") + .remove("OPENSHELL_TLS_KEY") + .set("OPENSHELL_TLS_CLIENT_CA", "/tls/ca.pem") + .run(|| { + let (run, matches) = parse_with_args(&["openshell-gateway"]); + let error = super::run_config_preflight( + super::ConfigPreflightArgs { + path: Some(config), + ..Default::default() + }, + run, + &matches, + ) + .expect_err( + "client CA without an explicit server pair must fail before cert generation", + ); + assert!(error.to_string().contains("category=malformed")); + }); } #[test] fn config_preflight_allows_complete_future_generated_tls_paths() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _config_env = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("gateway.toml"); - std::fs::write( + Environment::new() + .remove("OPENSHELL_GATEWAY_CONFIG") + .run(|| { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("gateway.toml"); + std::fs::write( &path, "[openshell]\nversion = 2\n[openshell.gateway]\nguest_tls_ca = '/future/ca.pem'\n", ) .unwrap(); - let (run, matches) = parse_with_args(&["openshell-gateway"]); + let (run, matches) = parse_with_args(&["openshell-gateway"]); - super::run_config_preflight( + super::run_config_preflight( super::ConfigPreflightArgs { path: Some(path), ..Default::default() @@ -2486,59 +2469,60 @@ mod tests { &matches, ) .expect("complete package-generated TLS paths may not exist before certificate generation"); + }); } #[test] fn generate_certs_backend_ca_configmap_flags_parse() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL"); - let _g2 = EnvVarGuard::remove("POD_NAMESPACE"); + Environment::new() + .remove("OPENSHELL_DB_URL") + .remove("POD_NAMESPACE") + .run(|| { + let cli = Cli::try_parse_from([ + "openshell-gateway", + "generate-certs", + "--namespace", + "openshell", + "--jwt-only", + "--jwt-secret-name", + "openshell-jwt-keys", + "--backend-ca-configmap-name", + "openshell-backend-ca", + "--backend-ca-source-secret", + "openshell-server-tls", + ]) + .expect("backend CA ConfigMap flags should parse with --jwt-only"); - let cli = Cli::try_parse_from([ - "openshell-gateway", - "generate-certs", - "--namespace", - "openshell", - "--jwt-only", - "--jwt-secret-name", - "openshell-jwt-keys", - "--backend-ca-configmap-name", - "openshell-backend-ca", - "--backend-ca-source-secret", - "openshell-server-tls", - ]) - .expect("backend CA ConfigMap flags should parse with --jwt-only"); - - assert!(matches!( - cli.command, - Some(super::Commands::GenerateCerts(_)) - )); + assert!(matches!( + cli.command, + Some(super::Commands::GenerateCerts(_)) + )); + }); } #[test] fn generate_certs_backend_ca_source_secret_requires_configmap_name() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL"); - let _g2 = EnvVarGuard::remove("POD_NAMESPACE"); + Environment::new() + .remove("OPENSHELL_DB_URL") + .remove("POD_NAMESPACE") + .run(|| { + let err = Cli::try_parse_from([ + "openshell-gateway", + "generate-certs", + "--namespace", + "openshell", + "--jwt-only", + "--jwt-secret-name", + "openshell-jwt-keys", + "--backend-ca-source-secret", + "openshell-server-tls", + ]) + .expect_err( + "--backend-ca-source-secret should require --backend-ca-configmap-name", + ); - let err = Cli::try_parse_from([ - "openshell-gateway", - "generate-certs", - "--namespace", - "openshell", - "--jwt-only", - "--jwt-secret-name", - "openshell-jwt-keys", - "--backend-ca-source-secret", - "openshell-server-tls", - ]) - .expect_err("--backend-ca-source-secret should require --backend-ca-configmap-name"); - - assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); + assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); + }); } #[test] @@ -2546,14 +2530,12 @@ mod tests { // db_url is Option at the clap level so subcommand parsing // does not require it. The Run path fills a default URL from XDG // state when neither CLI nor env supplied one. - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _g = EnvVarGuard::remove("OPENSHELL_DB_URL"); - let cli = Cli::try_parse_from(["openshell-gateway"]).expect("parses without --db-url"); - assert!(cli.command.is_none()); - assert!(cli.run.db_url.is_none()); + Environment::new().remove("OPENSHELL_DB_URL").run(|| { + let cli = Cli::try_parse_from(["openshell-gateway"]).expect("parses without --db-url"); + assert!(cli.command.is_none()); + assert!(cli.run.db_url.is_none()); + }); } // ── Config-file merge tests ────────────────────────────────────────── @@ -2588,437 +2570,439 @@ mod tests { #[test] fn default_config_path_is_loaded_only_when_present() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = tempfile::tempdir().unwrap(); - let _g1 = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); - let _g2 = EnvVarGuard::set("XDG_CONFIG_HOME", tmp.path().to_str().unwrap()); - let (args, _) = parse_with_args(&["openshell-gateway"]); - assert_eq!(super::resolve_config_path(&args).unwrap(), None); + Environment::new() + .remove("OPENSHELL_GATEWAY_CONFIG") + .set("XDG_CONFIG_HOME", tmp.path().to_str().unwrap()) + .run(|| { + let tmp = std::path::PathBuf::from(std::env::var_os("XDG_CONFIG_HOME").unwrap()); - let config = tmp.path().join("openshell").join("gateway.toml"); - std::fs::create_dir_all(config.parent().unwrap()).unwrap(); - std::fs::write(&config, "[openshell]\nversion = 2\n").unwrap(); + let (args, _) = parse_with_args(&["openshell-gateway"]); + assert_eq!(super::resolve_config_path(&args).unwrap(), None); - assert_eq!(super::resolve_config_path(&args).unwrap(), Some(config)); + let config = tmp.join("openshell").join("gateway.toml"); + std::fs::create_dir_all(config.parent().unwrap()).unwrap(); + std::fs::write(&config, "[openshell]\nversion = 2\n").unwrap(); + + assert_eq!(super::resolve_config_path(&args).unwrap(), Some(config)); + }); } #[test] fn explicit_config_path_is_returned_even_when_missing() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _g = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); - - let (args, _) = parse_with_args(&["openshell-gateway", "--config", "/tmp/missing.toml"]); + Environment::new() + .remove("OPENSHELL_GATEWAY_CONFIG") + .run(|| { + let (args, _) = + parse_with_args(&["openshell-gateway", "--config", "/tmp/missing.toml"]); - assert_eq!( - super::resolve_config_path(&args).unwrap(), - Some(std::path::PathBuf::from("/tmp/missing.toml")) - ); + assert_eq!( + super::resolve_config_path(&args).unwrap(), + Some(std::path::PathBuf::from("/tmp/missing.toml")) + ); + }); } #[test] fn runtime_defaults_populate_database_url_from_xdg_state() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = tempfile::tempdir().unwrap(); - let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL"); - let _g2 = EnvVarGuard::set("XDG_STATE_HOME", tmp.path().to_str().unwrap()); - - let (mut args, _) = parse_with_args(&["openshell-gateway", "--disable-tls"]); - let local_tls = super::apply_runtime_defaults(&mut args).unwrap(); - - let expected = format!( - "sqlite:{}", - tmp.path() - .join("openshell") - .join("gateway") - .join("openshell.db") - .display() - ); - assert!(local_tls.is_none()); - assert_eq!(args.db_url.as_deref(), Some(expected.as_str())); - assert!(tmp.path().join("openshell").join("gateway").is_dir()); + + Environment::new() + .remove("OPENSHELL_DB_URL") + .set("XDG_STATE_HOME", tmp.path().to_str().unwrap()) + .run(|| { + let tmp = std::path::PathBuf::from(std::env::var_os("XDG_STATE_HOME").unwrap()); + + let (mut args, _) = parse_with_args(&["openshell-gateway", "--disable-tls"]); + let local_tls = super::apply_runtime_defaults(&mut args).unwrap(); + + let expected = format!( + "sqlite:{}", + tmp.join("openshell") + .join("gateway") + .join("openshell.db") + .display() + ); + assert!(local_tls.is_none()); + assert_eq!(args.db_url.as_deref(), Some(expected.as_str())); + assert!(tmp.join("openshell").join("gateway").is_dir()); + }); } #[test] fn runtime_defaults_use_complete_local_tls_bundle() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let state = tempfile::tempdir().unwrap(); let tls = tempfile::tempdir().unwrap(); - let _g1 = EnvVarGuard::remove("OPENSHELL_DB_URL"); - let _g2 = EnvVarGuard::remove("OPENSHELL_TLS_CERT"); - let _g3 = EnvVarGuard::remove("OPENSHELL_TLS_KEY"); - let _g4 = EnvVarGuard::remove("OPENSHELL_TLS_CLIENT_CA"); - let _g5 = EnvVarGuard::remove("OPENSHELL_DISABLE_TLS"); - let _g6 = EnvVarGuard::set("XDG_STATE_HOME", state.path().to_str().unwrap()); - let _g7 = EnvVarGuard::set("OPENSHELL_LOCAL_TLS_DIR", tls.path().to_str().unwrap()); - - std::fs::create_dir_all(tls.path().join("server")).unwrap(); - std::fs::create_dir_all(tls.path().join("client")).unwrap(); - for rel in [ - "ca.crt", - "server/tls.crt", - "server/tls.key", - "client/tls.crt", - "client/tls.key", - ] { - std::fs::write(tls.path().join(rel), "pem").unwrap(); - } - let (mut args, _) = parse_with_args(&["openshell-gateway"]); - let local_tls = super::apply_runtime_defaults(&mut args) - .unwrap() - .expect("complete bundle should be returned"); + Environment::new() + .remove("OPENSHELL_DB_URL") + .remove("OPENSHELL_TLS_CERT") + .remove("OPENSHELL_TLS_KEY") + .remove("OPENSHELL_TLS_CLIENT_CA") + .remove("OPENSHELL_DISABLE_TLS") + .set("XDG_STATE_HOME", state.path().to_str().unwrap()) + .set("OPENSHELL_LOCAL_TLS_DIR", tls.path().to_str().unwrap()) + .run(|| { + let tls = + std::path::PathBuf::from(std::env::var_os("OPENSHELL_LOCAL_TLS_DIR").unwrap()); + + std::fs::create_dir_all(tls.join("server")).unwrap(); + std::fs::create_dir_all(tls.join("client")).unwrap(); + for rel in [ + "ca.crt", + "server/tls.crt", + "server/tls.key", + "client/tls.crt", + "client/tls.key", + ] { + std::fs::write(tls.join(rel), "pem").unwrap(); + } + + let (mut args, _) = parse_with_args(&["openshell-gateway"]); + let local_tls = super::apply_runtime_defaults(&mut args) + .unwrap() + .expect("complete bundle should be returned"); - assert_eq!(args.tls_cert, Some(tls.path().join("server/tls.crt"))); - assert_eq!(args.tls_key, Some(tls.path().join("server/tls.key"))); - assert_eq!(args.tls_client_ca, Some(tls.path().join("ca.crt"))); - assert_eq!(local_tls.client_cert, tls.path().join("client/tls.crt")); + assert_eq!(args.tls_cert, Some(tls.join("server/tls.crt"))); + assert_eq!(args.tls_key, Some(tls.join("server/tls.key"))); + assert_eq!(args.tls_client_ca, Some(tls.join("ca.crt"))); + assert_eq!(local_tls.client_cert, tls.join("client/tls.crt")); + }); } #[test] fn tls_accepts_bearer_clients_with_and_without_oidc() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); - let _config = EnvVarGuard::set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()); - let _config_path = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); - let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); - let registry = test_registry("shared", false); - for (oidc_issuer, expected) in [(None, false), (Some("https://idp.example.com"), false)] { - let mut startup_args = vec![ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--compute-driver", - "shared", - "--tls-cert", - "/tls/server.crt", - "--tls-key", - "/tls/server.key", - "--tls-client-ca", - "/tls/ca.crt", - ]; - if let Some(issuer) = oidc_issuer { - startup_args.extend(["--oidc-issuer", issuer]); - } - let (mut args, matches) = parse_with_args(&startup_args); - let prepared = - super::prepare_server_config_with_drivers(&mut args, &matches, ®istry).unwrap(); - - assert_eq!( - prepared.config.tls.as_ref().unwrap().require_client_auth, - expected, - "oidc issuer: {oidc_issuer:?}" - ); - } + Environment::new() + .set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()) + .remove("OPENSHELL_GATEWAY_CONFIG") + .remove("OPENSHELL_DRIVERS") + .run(|| { + let registry = test_registry("shared", false); + + for (oidc_issuer, expected) in + [(None, false), (Some("https://idp.example.com"), false)] + { + let mut startup_args = vec![ + "openshell-gateway", + "--db-url", + "sqlite::memory:", + "--compute-driver", + "shared", + "--tls-cert", + "/tls/server.crt", + "--tls-key", + "/tls/server.key", + "--tls-client-ca", + "/tls/ca.crt", + ]; + if let Some(issuer) = oidc_issuer { + startup_args.extend(["--oidc-issuer", issuer]); + } + let (mut args, matches) = parse_with_args(&startup_args); + let prepared = + super::prepare_server_config_with_drivers(&mut args, &matches, ®istry) + .unwrap(); + + assert_eq!( + prepared.config.tls.as_ref().unwrap().require_client_auth, + expected, + "oidc issuer: {oidc_issuer:?}" + ); + } + }); } #[test] fn mtls_auth_auto_defaults_when_client_ca_is_configured() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _guard = EnvVarGuard::remove("OPENSHELL_ENABLE_MTLS_AUTH"); - - let (args, matches) = parse_with_args(&[ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--compute-driver", - "local", - "--tls-cert", - "/tmp/server.crt", - "--tls-key", - "/tmp/server.key", - "--tls-client-ca", - "/tmp/ca.crt", - ]); + Environment::new() + .remove("OPENSHELL_ENABLE_MTLS_AUTH") + .run(|| { + let (args, matches) = parse_with_args(&[ + "openshell-gateway", + "--db-url", + "sqlite::memory:", + "--compute-driver", + "local", + "--tls-cert", + "/tmp/server.crt", + "--tls-key", + "/tmp/server.key", + "--tls-client-ca", + "/tmp/ca.crt", + ]); - assert!(super::resolve_mtls_auth_enabled(&args, &matches, None)); + assert!(super::resolve_mtls_auth_enabled(&args, &matches, None)); + }); } #[test] fn registry_detection_drives_auth_defaults_once() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let state = tempfile::tempdir().unwrap(); let config = tempfile::tempdir().unwrap(); - let _state = EnvVarGuard::set("XDG_STATE_HOME", state.path().to_str().unwrap()); - let _config = EnvVarGuard::set("XDG_CONFIG_HOME", config.path().to_str().unwrap()); - let _mtls = EnvVarGuard::remove("OPENSHELL_ENABLE_MTLS_AUTH"); - let _drivers = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); - REGISTRY_DETECTION_CALLS.store(0, Ordering::SeqCst); - let (mut args, matches) = parse_with_args(&[ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--tls-cert", - "/tmp/server.crt", - "--tls-key", - "/tmp/server.key", - "--tls-client-ca", - "/tmp/ca.crt", - ]); - let registry = detected_local_registry(); - - let prepared = - super::prepare_server_config_with_drivers(&mut args, &matches, ®istry).unwrap(); - - assert_eq!(prepared.compute_driver.name(), "local"); - assert!(prepared.config.compute_driver.is_none()); - assert!(prepared.config.mtls_auth.enabled); - assert!( - !prepared - .config - .tls - .as_ref() - .expect("TLS config") - .require_client_auth, - "sandbox bearer clients must be allowed through the TLS handshake" - ); - assert_eq!(REGISTRY_DETECTION_CALLS.load(Ordering::SeqCst), 1); + Environment::new() + .set("XDG_STATE_HOME", state.path().to_str().unwrap()) + .set("XDG_CONFIG_HOME", config.path().to_str().unwrap()) + .remove("OPENSHELL_ENABLE_MTLS_AUTH") + .remove("OPENSHELL_COMPUTE_DRIVER") + .run(|| { + REGISTRY_DETECTION_CALLS.store(0, Ordering::SeqCst); + + let (mut args, matches) = parse_with_args(&[ + "openshell-gateway", + "--db-url", + "sqlite::memory:", + "--tls-cert", + "/tmp/server.crt", + "--tls-key", + "/tmp/server.key", + "--tls-client-ca", + "/tmp/ca.crt", + ]); + let registry = detected_local_registry(); + + let prepared = + super::prepare_server_config_with_drivers(&mut args, &matches, ®istry) + .unwrap(); + + assert_eq!(prepared.compute_driver.name(), "local"); + assert!(prepared.config.compute_driver.is_none()); + assert!(prepared.config.mtls_auth.enabled); + assert!( + !prepared + .config + .tls + .as_ref() + .expect("TLS config") + .require_client_auth, + "sandbox bearer clients must be allowed through the TLS handshake" + ); + assert_eq!(REGISTRY_DETECTION_CALLS.load(Ordering::SeqCst), 1); + }); } #[test] fn mtls_auth_default_is_driver_independent() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _guard = EnvVarGuard::remove("OPENSHELL_ENABLE_MTLS_AUTH"); - - let (args, matches) = parse_with_args(&[ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--compute-driver", - "shared", - "--tls-cert", - "/tmp/server.crt", - "--tls-key", - "/tmp/server.key", - "--tls-client-ca", - "/tmp/ca.crt", - ]); + Environment::new() + .remove("OPENSHELL_ENABLE_MTLS_AUTH") + .run(|| { + let (args, matches) = parse_with_args(&[ + "openshell-gateway", + "--db-url", + "sqlite::memory:", + "--compute-driver", + "shared", + "--tls-cert", + "/tmp/server.crt", + "--tls-key", + "/tmp/server.key", + "--tls-client-ca", + "/tmp/ca.crt", + ]); - assert!(super::resolve_mtls_auth_enabled(&args, &matches, None)); + assert!(super::resolve_mtls_auth_enabled(&args, &matches, None)); + }); } #[test] fn file_mtls_auth_value_overrides_local_auto_default() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _guard = EnvVarGuard::remove("OPENSHELL_ENABLE_MTLS_AUTH"); - - let (mut args, matches) = parse_with_args(&[ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--compute-driver", - "local", - "--tls-cert", - "/tmp/server.crt", - "--tls-key", - "/tmp/server.key", - "--tls-client-ca", - "/tmp/ca.crt", - ]); - let file = config_file_from_toml( - r" + Environment::new() + .remove("OPENSHELL_ENABLE_MTLS_AUTH") + .run(|| { + let (mut args, matches) = parse_with_args(&[ + "openshell-gateway", + "--db-url", + "sqlite::memory:", + "--compute-driver", + "local", + "--tls-cert", + "/tmp/server.crt", + "--tls-key", + "/tmp/server.key", + "--tls-client-ca", + "/tmp/ca.crt", + ]); + let file = config_file_from_toml( + r" [openshell.gateway.mtls_auth] enabled = false ", - ); + ); - merge_file_into_args(&mut args, &file.openshell.gateway, &matches); + merge_file_into_args(&mut args, &file.openshell.gateway, &matches); - assert!(!super::resolve_mtls_auth_enabled( - &args, - &matches, - Some(&file) - )); + assert!(!super::resolve_mtls_auth_enabled( + &args, + &matches, + Some(&file) + )); + }); } #[test] fn operator_auth_is_opt_in_and_file_flag_precedence_is_explicit() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _env = EnvVarGuard::remove("OPENSHELL_ENABLE_OPERATOR_AUTH"); - let file = config_file_from_toml( - "[openshell.gateway.mtls_auth]\noperator_enabled = true\nenabled = false\n", - ); - let (mut args, matches) = parse_with_args(&["openshell-gateway"]); - assert!(!args.enable_operator_auth); - merge_file_into_args(&mut args, &file.openshell.gateway, &matches); - assert!(args.enable_operator_auth); - assert!(!args.enable_mtls_auth); - let (mut args, matches) = - parse_with_args(&["openshell-gateway", "--enable-operator-auth", "false"]); - merge_file_into_args(&mut args, &file.openshell.gateway, &matches); - assert!(!args.enable_operator_auth); + Environment::new() + .remove("OPENSHELL_ENABLE_OPERATOR_AUTH") + .run(|| { + let file = config_file_from_toml( + "[openshell.gateway.mtls_auth]\noperator_enabled = true\nenabled = false\n", + ); + let (mut args, matches) = parse_with_args(&["openshell-gateway"]); + assert!(!args.enable_operator_auth); + merge_file_into_args(&mut args, &file.openshell.gateway, &matches); + assert!(args.enable_operator_auth); + assert!(!args.enable_mtls_auth); + let (mut args, matches) = + parse_with_args(&["openshell-gateway", "--enable-operator-auth", "false"]); + merge_file_into_args(&mut args, &file.openshell.gateway, &matches); + assert!(!args.enable_operator_auth); + }); } #[test] fn operator_preflight_requires_tls_and_ca_independently_of_user_auth() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let config_home = tempfile::tempdir().unwrap(); - let _home = EnvVarGuard::set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()); - let _config = EnvVarGuard::remove("OPENSHELL_GATEWAY_CONFIG"); - let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); - let _env = EnvVarGuard::remove("OPENSHELL_ENABLE_OPERATOR_AUTH"); - let registry = test_registry("shared", false); - for tls_args in [vec!["--disable-tls"], vec![]] { - let mut argv = vec![ - "openshell-gateway", - "--compute-driver", - "shared", - "--enable-operator-auth", - "true", - "--enable-mtls-auth", - "false", - ]; - argv.extend(tls_args); - let (run, matches) = parse_with_args(&argv); - let error = super::run_config_preflight_with_drivers( - super::ConfigPreflightArgs::default(), - run, - &matches, - ®istry, - ) - .unwrap_err(); - assert!( - error - .to_string() - .contains("operator authentication requires TLS") - ); - } - let (run, matches) = parse_with_args(&[ - "openshell-gateway", - "--compute-driver", - "shared", - "--enable-operator-auth", - "true", - "--enable-mtls-auth", - "false", - "--tls-cert", - "/tls/server.pem", - "--tls-key", - "/tls/key.pem", - "--tls-client-ca", - "/tls/ca.pem", - ]); - super::run_config_preflight_with_drivers( - super::ConfigPreflightArgs::default(), - run, - &matches, - ®istry, - ) - .unwrap(); + Environment::new() + .set("XDG_CONFIG_HOME", config_home.path().to_str().unwrap()) + .remove("OPENSHELL_GATEWAY_CONFIG") + .remove("OPENSHELL_DRIVERS") + .remove("OPENSHELL_ENABLE_OPERATOR_AUTH") + .run(|| { + let registry = test_registry("shared", false); + for tls_args in [vec!["--disable-tls"], vec![]] { + let mut argv = vec![ + "openshell-gateway", + "--compute-driver", + "shared", + "--enable-operator-auth", + "true", + "--enable-mtls-auth", + "false", + ]; + argv.extend(tls_args); + let (run, matches) = parse_with_args(&argv); + let error = super::run_config_preflight_with_drivers( + super::ConfigPreflightArgs::default(), + run, + &matches, + ®istry, + ) + .unwrap_err(); + assert!( + error + .to_string() + .contains("operator authentication requires TLS") + ); + } + let (run, matches) = parse_with_args(&[ + "openshell-gateway", + "--compute-driver", + "shared", + "--enable-operator-auth", + "true", + "--enable-mtls-auth", + "false", + "--tls-cert", + "/tls/server.pem", + "--tls-key", + "/tls/key.pem", + "--tls-client-ca", + "/tls/ca.pem", + ]); + super::run_config_preflight_with_drivers( + super::ConfigPreflightArgs::default(), + run, + &matches, + ®istry, + ) + .unwrap(); + }); } #[test] fn file_value_applies_when_cli_uses_default() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _g1 = EnvVarGuard::remove("OPENSHELL_BIND_ADDRESS"); - let _g2 = EnvVarGuard::remove("OPENSHELL_SERVER_PORT"); - let _g3 = EnvVarGuard::remove("OPENSHELL_LOG_LEVEL"); - let _g4 = EnvVarGuard::remove("OPENSHELL_GATEWAY_NAME"); - - let (mut args, matches) = - parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); - let file = config_file_from_toml( - r#" + Environment::new() + .remove("OPENSHELL_BIND_ADDRESS") + .remove("OPENSHELL_SERVER_PORT") + .remove("OPENSHELL_LOG_LEVEL") + .remove("OPENSHELL_GATEWAY_NAME") + .run(|| { + let (mut args, matches) = + parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); + let file = config_file_from_toml( + r#" [openshell.gateway] name = "production-us-west" bind_address = "0.0.0.0:9090" log_level = "debug" "#, - ); - merge_file_into_args(&mut args, &file.openshell.gateway, &matches); + ); + merge_file_into_args(&mut args, &file.openshell.gateway, &matches); - assert_eq!(args.bind_address, IpAddr::V4(Ipv4Addr::UNSPECIFIED)); - assert_eq!(args.port, 9090); - assert_eq!(args.log_level, "debug"); - assert_eq!(args.name, "production-us-west"); + assert_eq!(args.bind_address, IpAddr::V4(Ipv4Addr::UNSPECIFIED)); + assert_eq!(args.port, 9090); + assert_eq!(args.log_level, "debug"); + assert_eq!(args.name, "production-us-west"); + }); } #[test] fn cli_flag_overrides_file_value() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _g1 = EnvVarGuard::remove("OPENSHELL_BIND_ADDRESS"); - let _g2 = EnvVarGuard::remove("OPENSHELL_LOG_LEVEL"); - let _g3 = EnvVarGuard::remove("OPENSHELL_GATEWAY_NAME"); - - let (mut args, matches) = parse_with_args(&[ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--log-level", - "warn", - "--name", - "cli-gateway", - ]); - let file = config_file_from_toml( - r#" + Environment::new() + .remove("OPENSHELL_BIND_ADDRESS") + .remove("OPENSHELL_LOG_LEVEL") + .remove("OPENSHELL_GATEWAY_NAME") + .run(|| { + let (mut args, matches) = parse_with_args(&[ + "openshell-gateway", + "--db-url", + "sqlite::memory:", + "--log-level", + "warn", + "--name", + "cli-gateway", + ]); + let file = config_file_from_toml( + r#" [openshell.gateway] name = "file-gateway" log_level = "debug" "#, - ); - merge_file_into_args(&mut args, &file.openshell.gateway, &matches); + ); + merge_file_into_args(&mut args, &file.openshell.gateway, &matches); - assert_eq!(args.log_level, "warn", "CLI flag must win over file"); - assert_eq!(args.name, "cli-gateway"); + assert_eq!(args.log_level, "warn", "CLI flag must win over file"); + assert_eq!(args.name, "cli-gateway"); + }); } #[test] fn env_var_overrides_file_value() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _g = EnvVarGuard::set("OPENSHELL_LOG_LEVEL", "trace"); - let _g2 = EnvVarGuard::set("OPENSHELL_GATEWAY_NAME", "env-gateway"); - - let (mut args, matches) = - parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); - let file = config_file_from_toml( - r#" + Environment::new() + .set("OPENSHELL_LOG_LEVEL", "trace") + .set("OPENSHELL_GATEWAY_NAME", "env-gateway") + .run(|| { + let (mut args, matches) = + parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); + let file = config_file_from_toml( + r#" [openshell.gateway] name = "file-gateway" log_level = "debug" "#, - ); - merge_file_into_args(&mut args, &file.openshell.gateway, &matches); + ); + merge_file_into_args(&mut args, &file.openshell.gateway, &matches); - assert_eq!(args.log_level, "trace", "env var must win over file"); - assert_eq!(args.name, "env-gateway"); + assert_eq!(args.log_level, "trace", "env var must win over file"); + assert_eq!(args.name, "env-gateway"); + }); } #[test] fn compute_driver_file_value_and_cli_environment_precedence_are_explicit() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _legacy = EnvVarGuard::remove("OPENSHELL_DRIVERS"); let file = config_file_from_toml( r#" [openshell.gateway] @@ -3026,82 +3010,88 @@ compute_driver = "podman" "#, ); - let canonical_guard = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); - let (mut file_args, file_matches) = - parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); - merge_file_into_args(&mut file_args, &file.openshell.gateway, &file_matches); - assert_eq!(file_args.compute_driver.as_deref(), Some("podman")); + Environment::new() + .remove("OPENSHELL_DRIVERS") + .remove("OPENSHELL_COMPUTE_DRIVER") + .run(|| { + let (mut file_args, file_matches) = + parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); + merge_file_into_args(&mut file_args, &file.openshell.gateway, &file_matches); + assert_eq!(file_args.compute_driver.as_deref(), Some("podman")); - let (mut cli_args, cli_matches) = parse_with_args(&[ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--compute-driver", - "docker", - ]); - merge_file_into_args(&mut cli_args, &file.openshell.gateway, &cli_matches); - assert_eq!(cli_args.compute_driver.as_deref(), Some("docker")); - drop(canonical_guard); + let (mut cli_args, cli_matches) = parse_with_args(&[ + "openshell-gateway", + "--db-url", + "sqlite::memory:", + "--compute-driver", + "docker", + ]); + merge_file_into_args(&mut cli_args, &file.openshell.gateway, &cli_matches); + assert_eq!(cli_args.compute_driver.as_deref(), Some("docker")); + }); - let _canonical = EnvVarGuard::set("OPENSHELL_COMPUTE_DRIVER", "vm"); - let (mut env_args, env_matches) = - parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); - merge_file_into_args(&mut env_args, &file.openshell.gateway, &env_matches); - assert_eq!(env_args.compute_driver.as_deref(), Some("vm")); + Environment::new() + .remove("OPENSHELL_DRIVERS") + .set("OPENSHELL_COMPUTE_DRIVER", "vm") + .run(|| { + let (mut env_args, env_matches) = + parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); + merge_file_into_args(&mut env_args, &file.openshell.gateway, &env_matches); + assert_eq!(env_args.compute_driver.as_deref(), Some("vm")); + }); } #[test] fn legacy_compute_driver_environment_conflicts_with_file_selection() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _canonical = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); - let _legacy = EnvVarGuard::set("OPENSHELL_DRIVERS", "docker"); - let file = config_file_from_toml( - r#" + Environment::new() + .remove("OPENSHELL_COMPUTE_DRIVER") + .set("OPENSHELL_DRIVERS", "docker") + .run(|| { + let file = config_file_from_toml( + r#" [openshell.gateway] compute_driver = "podman" "#, - ); - let (mut args, matches) = - parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); - merge_file_into_args(&mut args, &file.openshell.gateway, &matches); + ); + let (mut args, matches) = + parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); + merge_file_into_args(&mut args, &file.openshell.gateway, &matches); - let error = super::resolve_legacy_driver_selector_env(&mut args) - .expect_err("different file and legacy selectors must conflict"); - assert!(error.to_string().contains("conflicts")); + let error = super::resolve_legacy_driver_selector_env(&mut args) + .expect_err("different file and legacy selectors must conflict"); + assert!(error.to_string().contains("conflicts")); + }); } #[test] fn file_oidc_block_populates_oidc_args() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _g1 = EnvVarGuard::remove("OPENSHELL_OIDC_ISSUER"); - let _g2 = EnvVarGuard::remove("OPENSHELL_OIDC_AUDIENCE"); - let _g3 = EnvVarGuard::remove("OPENSHELL_OIDC_DANGEROUSLY_ALLOW_INSECURE_HTTP"); - let _g4 = EnvVarGuard::remove("OPENSHELL_OIDC_JWKS_ALLOWED_ORIGINS"); - - let (mut args, matches) = - parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); - let file = config_file_from_toml( - r#" + Environment::new() + .remove("OPENSHELL_OIDC_ISSUER") + .remove("OPENSHELL_OIDC_AUDIENCE") + .remove("OPENSHELL_OIDC_DANGEROUSLY_ALLOW_INSECURE_HTTP") + .remove("OPENSHELL_OIDC_JWKS_ALLOWED_ORIGINS") + .run(|| { + let (mut args, matches) = + parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); + let file = config_file_from_toml( + r#" [openshell.gateway.oidc] issuer = "https://idp.example.com" audience = "openshell-cli" dangerously_allow_insecure_http = true jwks_allowed_origins = ["https://keys.example.com"] "#, - ); - merge_file_into_args(&mut args, &file.openshell.gateway, &matches); + ); + merge_file_into_args(&mut args, &file.openshell.gateway, &matches); - assert_eq!(args.oidc_issuer.as_deref(), Some("https://idp.example.com")); - assert_eq!(args.oidc_audience, "openshell-cli"); - assert!(args.oidc_dangerously_allow_insecure_http); - assert_eq!( - args.oidc_jwks_allowed_origins, - ["https://keys.example.com".to_string()] - ); + assert_eq!(args.oidc_issuer.as_deref(), Some("https://idp.example.com")); + assert_eq!(args.oidc_audience, "openshell-cli"); + assert!(args.oidc_dangerously_allow_insecure_http); + assert_eq!( + args.oidc_jwks_allowed_origins, + ["https://keys.example.com".to_string()] + ); + }); } #[test] @@ -3146,75 +3136,67 @@ grpc_rate_limit_window_seconds = 30 #[test] fn aux_listener_preserves_file_ip_against_public_bind() { use std::net::SocketAddr; - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _g = EnvVarGuard::remove("OPENSHELL_HEALTH_PORT"); - let (_args, matches) = - parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); - let file_addr: SocketAddr = "127.0.0.1:8081".parse().unwrap(); - let resolved = super::resolve_aux_listener( - IpAddr::V4(Ipv4Addr::UNSPECIFIED), - 0, - &matches, - "health_port", - || Some(file_addr), - ); - assert_eq!( - resolved, - Some(file_addr), - "TOML health_bind_address 127.0.0.1:8081 must not be relocated to 0.0.0.0:8081" - ); + Environment::new().remove("OPENSHELL_HEALTH_PORT").run(|| { + let (_args, matches) = + parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); + let file_addr: SocketAddr = "127.0.0.1:8081".parse().unwrap(); + let resolved = super::resolve_aux_listener( + IpAddr::V4(Ipv4Addr::UNSPECIFIED), + 0, + &matches, + "health_port", + || Some(file_addr), + ); + assert_eq!( + resolved, + Some(file_addr), + "TOML health_bind_address 127.0.0.1:8081 must not be relocated to 0.0.0.0:8081" + ); + }); } #[test] fn aux_listener_cli_port_overrides_file_addr() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _g = EnvVarGuard::remove("OPENSHELL_HEALTH_PORT"); - - let (_args, matches) = parse_with_args(&[ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--health-port", - "9999", - ]); - let file_addr: std::net::SocketAddr = "127.0.0.1:8081".parse().unwrap(); - let resolved = super::resolve_aux_listener( - IpAddr::V4(Ipv4Addr::UNSPECIFIED), - 9999, - &matches, - "health_port", - || Some(file_addr), - ); - assert_eq!( - resolved, - Some("0.0.0.0:9999".parse().unwrap()), - "CLI flag must win over file value" - ); + Environment::new().remove("OPENSHELL_HEALTH_PORT").run(|| { + let (_args, matches) = parse_with_args(&[ + "openshell-gateway", + "--db-url", + "sqlite::memory:", + "--health-port", + "9999", + ]); + let file_addr: std::net::SocketAddr = "127.0.0.1:8081".parse().unwrap(); + let resolved = super::resolve_aux_listener( + IpAddr::V4(Ipv4Addr::UNSPECIFIED), + 9999, + &matches, + "health_port", + || Some(file_addr), + ); + assert_eq!( + resolved, + Some("0.0.0.0:9999".parse().unwrap()), + "CLI flag must win over file value" + ); + }); } #[test] fn file_disable_tls_applies() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _g = EnvVarGuard::remove("OPENSHELL_DISABLE_TLS"); - - let (mut args, matches) = - parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); - let file = config_file_from_toml( - r" + Environment::new().remove("OPENSHELL_DISABLE_TLS").run(|| { + let (mut args, matches) = + parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); + let file = config_file_from_toml( + r" [openshell.gateway] disable_tls = true ", - ); - merge_file_into_args(&mut args, &file.openshell.gateway, &matches); + ); + merge_file_into_args(&mut args, &file.openshell.gateway, &matches); - assert!(args.disable_tls); + assert!(args.disable_tls); + }); } #[test] @@ -3234,164 +3216,158 @@ ssh_session_ttl_secs = 1234 #[test] fn compute_driver_socket_flag_uses_explicit_driver_name() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _g1 = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER_SOCKET"); - let _g2 = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); - - let (mut args, _) = parse_with_args(&[ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--compute-driver", - "Kyma", - "--compute-driver-socket", - "/run/openshell/kyma.sock", - ]); - super::normalize_compute_driver_socket_args(&mut args).unwrap(); - assert_eq!( - args.compute_driver_socket.as_deref(), - Some(std::path::Path::new("/run/openshell/kyma.sock")) - ); - assert_eq!(args.compute_driver.as_deref(), Some("kyma")); + Environment::new() + .remove("OPENSHELL_COMPUTE_DRIVER_SOCKET") + .remove("OPENSHELL_COMPUTE_DRIVER") + .run(|| { + let (mut args, _) = parse_with_args(&[ + "openshell-gateway", + "--db-url", + "sqlite::memory:", + "--compute-driver", + "Kyma", + "--compute-driver-socket", + "/run/openshell/kyma.sock", + ]); + super::normalize_compute_driver_socket_args(&mut args).unwrap(); + assert_eq!( + args.compute_driver_socket.as_deref(), + Some(std::path::Path::new("/run/openshell/kyma.sock")) + ); + assert_eq!(args.compute_driver.as_deref(), Some("kyma")); + }); } #[test] fn compute_driver_socket_requires_explicit_driver_name() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _g1 = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER_SOCKET"); - let _g2 = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); - - let (mut args, _) = parse_with_args(&[ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--compute-driver-socket", - "/run/openshell/kyma.sock", - ]); - let err = super::normalize_compute_driver_socket_args(&mut args).unwrap_err(); + Environment::new() + .remove("OPENSHELL_COMPUTE_DRIVER_SOCKET") + .remove("OPENSHELL_COMPUTE_DRIVER") + .run(|| { + let (mut args, _) = parse_with_args(&[ + "openshell-gateway", + "--db-url", + "sqlite::memory:", + "--compute-driver-socket", + "/run/openshell/kyma.sock", + ]); + let err = super::normalize_compute_driver_socket_args(&mut args).unwrap_err(); - assert!( - err.to_string().contains("requires --compute-driver "), - "unexpected error: {err}" - ); + assert!( + err.to_string().contains("requires --compute-driver "), + "unexpected error: {err}" + ); + }); } #[test] fn compute_driver_socket_accepts_canonical_builtin_driver_name() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _g1 = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER_SOCKET"); - let _g2 = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); - - let (mut args, _) = parse_with_args(&[ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--compute-driver", - "docker", - "--compute-driver-socket", - "/run/openshell/extension.sock", - ]); - super::normalize_compute_driver_socket_args(&mut args).unwrap(); - assert_eq!(args.compute_driver.as_deref(), Some("docker")); + Environment::new() + .remove("OPENSHELL_COMPUTE_DRIVER_SOCKET") + .remove("OPENSHELL_COMPUTE_DRIVER") + .run(|| { + let (mut args, _) = parse_with_args(&[ + "openshell-gateway", + "--db-url", + "sqlite::memory:", + "--compute-driver", + "docker", + "--compute-driver-socket", + "/run/openshell/extension.sock", + ]); + super::normalize_compute_driver_socket_args(&mut args).unwrap(); + assert_eq!(args.compute_driver.as_deref(), Some("docker")); + }); } #[test] fn compute_driver_socket_accepts_vm_endpoint() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _g1 = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER_SOCKET"); - let _g2 = EnvVarGuard::remove("OPENSHELL_COMPUTE_DRIVER"); - - let (mut args, _) = parse_with_args(&[ - "openshell-gateway", - "--db-url", - "sqlite::memory:", - "--compute-driver", - "vm", - "--compute-driver-socket", - "/run/openshell/vm.sock", - ]); - super::normalize_compute_driver_socket_args(&mut args).unwrap(); - assert_eq!(args.compute_driver.as_deref(), Some("vm")); + Environment::new() + .remove("OPENSHELL_COMPUTE_DRIVER_SOCKET") + .remove("OPENSHELL_COMPUTE_DRIVER") + .run(|| { + let (mut args, _) = parse_with_args(&[ + "openshell-gateway", + "--db-url", + "sqlite::memory:", + "--compute-driver", + "vm", + "--compute-driver-socket", + "/run/openshell/vm.sock", + ]); + super::normalize_compute_driver_socket_args(&mut args).unwrap(); + assert_eq!(args.compute_driver.as_deref(), Some("vm")); + }); } #[test] fn compute_driver_socket_reads_from_env_var() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _g1 = EnvVarGuard::set( - "OPENSHELL_COMPUTE_DRIVER_SOCKET", - "/var/run/openshell/kyma.sock", - ); - let _g2 = EnvVarGuard::set("OPENSHELL_COMPUTE_DRIVER", "kyma"); - - let (mut args, _) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); - super::normalize_compute_driver_socket_args(&mut args).unwrap(); - assert_eq!( - args.compute_driver_socket.as_deref(), - Some(std::path::Path::new("/var/run/openshell/kyma.sock")) - ); - assert_eq!(args.compute_driver.as_deref(), Some("kyma")); + Environment::new() + .set( + "OPENSHELL_COMPUTE_DRIVER_SOCKET", + "/var/run/openshell/kyma.sock", + ) + .set("OPENSHELL_COMPUTE_DRIVER", "kyma") + .run(|| { + let (mut args, _) = + parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); + super::normalize_compute_driver_socket_args(&mut args).unwrap(); + assert_eq!( + args.compute_driver_socket.as_deref(), + Some(std::path::Path::new("/var/run/openshell/kyma.sock")) + ); + assert_eq!(args.compute_driver.as_deref(), Some("kyma")); + }); } #[test] fn file_populates_service_routing_fields() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _g1 = EnvVarGuard::remove("OPENSHELL_SERVER_SAN"); - let _g2 = EnvVarGuard::remove("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP"); - - let (mut args, matches) = - parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); - let file = config_file_from_toml( - r#" + Environment::new() + .remove("OPENSHELL_SERVER_SAN") + .remove("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP") + .run(|| { + let (mut args, matches) = + parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); + let file = config_file_from_toml( + r#" [openshell.gateway] server_sans = ["gateway.local", "*.dev.openshell.localhost"] enable_loopback_service_http = false "#, - ); - merge_file_into_args(&mut args, &file.openshell.gateway, &matches); + ); + merge_file_into_args(&mut args, &file.openshell.gateway, &matches); - assert_eq!( - args.server_sans, - vec![ - "gateway.local".to_string(), - "*.dev.openshell.localhost".to_string() - ] - ); - assert!(!args.enable_loopback_service_http); + assert_eq!( + args.server_sans, + vec![ + "gateway.local".to_string(), + "*.dev.openshell.localhost".to_string() + ] + ); + assert!(!args.enable_loopback_service_http); + }); } #[test] fn env_var_overrides_file_loopback_service_http() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _g = EnvVarGuard::set("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP", "true"); - - let (mut args, matches) = - parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); - let file = config_file_from_toml( - r" + Environment::new() + .set("OPENSHELL_ENABLE_LOOPBACK_SERVICE_HTTP", "true") + .run(|| { + let (mut args, matches) = + parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); + let file = config_file_from_toml( + r" [openshell.gateway] enable_loopback_service_http = false ", - ); - merge_file_into_args(&mut args, &file.openshell.gateway, &matches); + ); + merge_file_into_args(&mut args, &file.openshell.gateway, &matches); - assert!( - args.enable_loopback_service_http, - "env var must win over file" - ); + assert!( + args.enable_loopback_service_http, + "env var must win over file" + ); + }); } #[test] @@ -3407,20 +3383,22 @@ enable_loopback_service_http = false #[test] fn server_config_preparation_ignores_unselected_driver_tables() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let state = tempfile::tempdir().unwrap(); let local_tls = tempfile::tempdir().unwrap(); - let _g1 = EnvVarGuard::set("XDG_STATE_HOME", state.path().to_str().unwrap()); - let _g2 = EnvVarGuard::set( - "OPENSHELL_LOCAL_TLS_DIR", - local_tls.path().to_str().unwrap(), - ); - let config_path = state.path().join("gateway.toml"); - std::fs::write( - &config_path, - r#" + + Environment::new() + .set("XDG_STATE_HOME", state.path().to_str().unwrap()) + .set( + "OPENSHELL_LOCAL_TLS_DIR", + local_tls.path().to_str().unwrap(), + ) + .run(|| { + let state = std::path::PathBuf::from(std::env::var_os("XDG_STATE_HOME").unwrap()); + + let config_path = state.join("gateway.toml"); + std::fs::write( + &config_path, + r#" [openshell] version = 2 @@ -3434,44 +3412,45 @@ unknown_docker_key = true [openshell.drivers.vm] mem_mib = "not-a-number" "#, - ) - .unwrap(); + ) + .unwrap(); - let (mut args, matches) = parse_with_args(&[ - "openshell-gateway", - "--config", - config_path.to_str().unwrap(), - "--db-url", - "sqlite::memory:", - "--compute-driver", - "podman", - "--disable-tls", - ]); + let (mut args, matches) = parse_with_args(&[ + "openshell-gateway", + "--config", + config_path.to_str().unwrap(), + "--db-url", + "sqlite::memory:", + "--compute-driver", + "podman", + "--disable-tls", + ]); - let prepared = - super::prepare_server_config(&mut args, &matches).expect("server config is prepared"); + let prepared = super::prepare_server_config(&mut args, &matches) + .expect("server config is prepared"); - assert_eq!(prepared.config.compute_driver.as_deref(), Some("podman")); - assert_eq!(prepared.config.image_preparation_timeout_seconds, 2400); - assert_eq!( - prepared.config.policy_validation_failure_mode, - openshell_core::PolicyValidationFailureMode::RetainLastValid - ); - let file = prepared.config_file.expect("config file is preserved"); - assert!(file.openshell.drivers.contains_key("docker")); - assert!(file.openshell.drivers.contains_key("vm")); + assert_eq!(prepared.config.compute_driver.as_deref(), Some("podman")); + assert_eq!(prepared.config.image_preparation_timeout_seconds, 2400); + assert_eq!( + prepared.config.policy_validation_failure_mode, + openshell_core::PolicyValidationFailureMode::RetainLastValid + ); + let file = prepared.config_file.expect("config file is preserved"); + assert!(file.openshell.drivers.contains_key("docker")); + assert!(file.openshell.drivers.contains_key("vm")); + }); } #[test] fn server_config_rejects_unbounded_image_preparation() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let state = tempfile::tempdir().unwrap(); let tls = tempfile::tempdir().unwrap(); - let _state = EnvVarGuard::set("XDG_STATE_HOME", state.path().to_str().unwrap()); - let _tls = EnvVarGuard::set("OPENSHELL_LOCAL_TLS_DIR", tls.path().to_str().unwrap()); - let config_path = state.path().join("gateway.toml"); + Environment::new() + .set("XDG_STATE_HOME", state.path().to_str().unwrap()) + .set("OPENSHELL_LOCAL_TLS_DIR", tls.path().to_str().unwrap()) + .run(|| { + let state = std::path::PathBuf::from(std::env::var_os("XDG_STATE_HOME").unwrap()); + let config_path = state.join("gateway.toml"); for seconds in [0, 86_401] { std::fs::write(&config_path, format!( "[openshell]\nversion = 2\n[openshell.gateway]\nimage_preparation_timeout_seconds = {seconds}\n" @@ -3495,29 +3474,28 @@ mem_mib = "not-a-number" .contains("image_preparation_timeout_seconds must be between 1 and 86400") ); } + }); } #[test] fn explicit_launch_signing_config_ignores_partial_local_bundle() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let directory = tempfile::tempdir().unwrap(); - let _state = EnvVarGuard::set("XDG_STATE_HOME", directory.path().to_str().unwrap()); - let _local = EnvVarGuard::set( - "OPENSHELL_LOCAL_TLS_DIR", - directory.path().to_str().unwrap(), - ); - std::fs::create_dir(directory.path().join("jwt")).unwrap(); - std::fs::write( - directory.path().join("jwt/signing.pem"), - "incomplete local bundle", - ) - .unwrap(); - let config_path = directory.path().join("gateway.toml"); - std::fs::write( - &config_path, - r#" + Environment::new() + .set("XDG_STATE_HOME", directory.path().to_str().unwrap()) + .set( + "OPENSHELL_LOCAL_TLS_DIR", + directory.path().to_str().unwrap(), + ) + .run(|| { + let directory = + std::path::PathBuf::from(std::env::var_os("XDG_STATE_HOME").unwrap()); + std::fs::create_dir(directory.join("jwt")).unwrap(); + std::fs::write(directory.join("jwt/signing.pem"), "incomplete local bundle") + .unwrap(); + let config_path = directory.join("gateway.toml"); + std::fs::write( + &config_path, + r#" [openshell] version = 2 [openshell.gateway.gateway_jwt] @@ -3526,22 +3504,23 @@ public_key_path = "/explicit/public.pem" kid_path = "/explicit/kid" gateway_id = "explicit-gateway" "#, - ) - .unwrap(); - let (mut args, matches) = parse_with_args(&[ - "openshell-gateway", - "--config", - config_path.to_str().unwrap(), - "--db-url", - "sqlite::memory:", - "--compute-driver", - "podman", - "--disable-tls", - ]); - let prepared = super::prepare_server_config(&mut args, &matches).unwrap(); - assert_eq!( - prepared.config.gateway_jwt.unwrap().gateway_id, - "explicit-gateway" - ); + ) + .unwrap(); + let (mut args, matches) = parse_with_args(&[ + "openshell-gateway", + "--config", + config_path.to_str().unwrap(), + "--db-url", + "sqlite::memory:", + "--compute-driver", + "podman", + "--disable-tls", + ]); + let prepared = super::prepare_server_config(&mut args, &matches).unwrap(); + assert_eq!( + prepared.config.gateway_jwt.unwrap().gateway_id, + "explicit-gateway" + ); + }); } } diff --git a/crates/openshell-server/src/defaults.rs b/crates/openshell-server/src/defaults.rs index 390dad1eff..af38593fd6 100644 --- a/crates/openshell-server/src/defaults.rs +++ b/crates/openshell-server/src/defaults.rs @@ -120,127 +120,112 @@ pub fn local_jwt_config(dir: &Path) -> Result> { #[cfg(test)] mod tests { use super::*; - use crate::TEST_ENV_LOCK as ENV_LOCK; - - struct EnvVarGuard { - key: &'static str, - original: Option, - } - - impl EnvVarGuard { - #[allow(unsafe_code)] - fn set(key: &'static str, value: &Path) -> Self { - let original = std::env::var(key).ok(); - // SAFETY: tests serialize environment mutation with ENV_LOCK. - unsafe { std::env::set_var(key, value) }; - Self { key, original } - } - } - - impl Drop for EnvVarGuard { - #[allow(unsafe_code)] - fn drop(&mut self) { - match self.original.as_deref() { - // SAFETY: tests serialize environment mutation with ENV_LOCK. - Some(value) => unsafe { std::env::set_var(self.key, value) }, - // SAFETY: tests serialize environment mutation with ENV_LOCK. - None => unsafe { std::env::remove_var(self.key) }, - } - } - } + use crate::test_environment::Environment; #[test] fn complete_local_tls_paths_returns_none_when_bundle_absent() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = tempfile::tempdir().unwrap(); - let _guard = EnvVarGuard::set("OPENSHELL_LOCAL_TLS_DIR", tmp.path()); - assert!(complete_local_tls_paths().unwrap().is_none()); + Environment::new() + .set("OPENSHELL_LOCAL_TLS_DIR", tmp.path()) + .run(|| { + assert!(complete_local_tls_paths().unwrap().is_none()); + }); } #[test] fn complete_local_tls_paths_rejects_partial_bundle() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = tempfile::tempdir().unwrap(); - let _guard = EnvVarGuard::set("OPENSHELL_LOCAL_TLS_DIR", tmp.path()); - std::fs::write(tmp.path().join("ca.crt"), "ca").unwrap(); - let err = complete_local_tls_paths().unwrap_err(); - assert!(err.to_string().contains("partial local TLS state")); + Environment::new() + .set("OPENSHELL_LOCAL_TLS_DIR", tmp.path()) + .run(|| { + let tmp = PathBuf::from(std::env::var_os("OPENSHELL_LOCAL_TLS_DIR").unwrap()); + + std::fs::write(tmp.join("ca.crt"), "ca").unwrap(); + + let err = complete_local_tls_paths().unwrap_err(); + assert!(err.to_string().contains("partial local TLS state")); + }); } #[test] fn complete_local_tls_paths_returns_full_bundle() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = tempfile::tempdir().unwrap(); - let _guard = EnvVarGuard::set("OPENSHELL_LOCAL_TLS_DIR", tmp.path()); - std::fs::create_dir_all(tmp.path().join("server")).unwrap(); - std::fs::create_dir_all(tmp.path().join("client")).unwrap(); - for rel in [ - "ca.crt", - "server/tls.crt", - "server/tls.key", - "client/tls.crt", - "client/tls.key", - ] { - std::fs::write(tmp.path().join(rel), "pem").unwrap(); - } - let paths = complete_local_tls_paths().unwrap().unwrap(); - assert_eq!(paths.ca, tmp.path().join("ca.crt")); - assert_eq!(paths.server_cert, tmp.path().join("server/tls.crt")); - assert_eq!(paths.client_key, tmp.path().join("client/tls.key")); + Environment::new() + .set("OPENSHELL_LOCAL_TLS_DIR", tmp.path()) + .run(|| { + let tmp = PathBuf::from(std::env::var_os("OPENSHELL_LOCAL_TLS_DIR").unwrap()); + + std::fs::create_dir_all(tmp.join("server")).unwrap(); + std::fs::create_dir_all(tmp.join("client")).unwrap(); + for rel in [ + "ca.crt", + "server/tls.crt", + "server/tls.key", + "client/tls.crt", + "client/tls.key", + ] { + std::fs::write(tmp.join(rel), "pem").unwrap(); + } + + let paths = complete_local_tls_paths().unwrap().unwrap(); + assert_eq!(paths.ca, tmp.join("ca.crt")); + assert_eq!(paths.server_cert, tmp.join("server/tls.crt")); + assert_eq!(paths.client_key, tmp.join("client/tls.key")); + }); } #[test] fn complete_local_jwt_config_returns_none_when_bundle_absent() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = tempfile::tempdir().unwrap(); - let _guard = EnvVarGuard::set("OPENSHELL_LOCAL_TLS_DIR", tmp.path()); - assert!(complete_local_jwt_config().unwrap().is_none()); + Environment::new() + .set("OPENSHELL_LOCAL_TLS_DIR", tmp.path()) + .run(|| { + assert!(complete_local_jwt_config().unwrap().is_none()); + }); } #[test] fn complete_local_jwt_config_rejects_partial_bundle() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = tempfile::tempdir().unwrap(); - let _guard = EnvVarGuard::set("OPENSHELL_LOCAL_TLS_DIR", tmp.path()); - std::fs::create_dir_all(tmp.path().join("jwt")).unwrap(); - std::fs::write(tmp.path().join("jwt/signing.pem"), "key").unwrap(); - let err = complete_local_jwt_config().unwrap_err(); - assert!(err.to_string().contains("partial local sandbox JWT state")); + Environment::new() + .set("OPENSHELL_LOCAL_TLS_DIR", tmp.path()) + .run(|| { + let tmp = PathBuf::from(std::env::var_os("OPENSHELL_LOCAL_TLS_DIR").unwrap()); + + std::fs::create_dir_all(tmp.join("jwt")).unwrap(); + std::fs::write(tmp.join("jwt/signing.pem"), "key").unwrap(); + + let err = complete_local_jwt_config().unwrap_err(); + assert!(err.to_string().contains("partial local sandbox JWT state")); + }); } #[test] fn complete_local_jwt_config_returns_full_bundle() { - let _lock = ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); let tmp = tempfile::tempdir().unwrap(); - let _guard = EnvVarGuard::set("OPENSHELL_LOCAL_TLS_DIR", tmp.path()); - std::fs::create_dir_all(tmp.path().join("jwt")).unwrap(); - for rel in ["jwt/signing.pem", "jwt/public.pem", "jwt/kid"] { - std::fs::write(tmp.path().join(rel), "pem").unwrap(); - } - let config = complete_local_jwt_config().unwrap().unwrap(); + Environment::new() + .set("OPENSHELL_LOCAL_TLS_DIR", tmp.path()) + .run(|| { + let tmp = PathBuf::from(std::env::var_os("OPENSHELL_LOCAL_TLS_DIR").unwrap()); + + std::fs::create_dir_all(tmp.join("jwt")).unwrap(); + for rel in ["jwt/signing.pem", "jwt/public.pem", "jwt/kid"] { + std::fs::write(tmp.join(rel), "pem").unwrap(); + } + + let config = complete_local_jwt_config().unwrap().unwrap(); - assert_eq!(config.signing_key_path, tmp.path().join("jwt/signing.pem")); - assert_eq!(config.public_key_path, tmp.path().join("jwt/public.pem")); - assert_eq!(config.kid_path, tmp.path().join("jwt/kid")); - assert_eq!(config.gateway_id, "openshell"); - assert_eq!(config.ttl_secs, None); + assert_eq!(config.signing_key_path, tmp.join("jwt/signing.pem")); + assert_eq!(config.public_key_path, tmp.join("jwt/public.pem")); + assert_eq!(config.kid_path, tmp.join("jwt/kid")); + assert_eq!(config.gateway_id, "openshell"); + assert_eq!(config.ttl_secs, None); + }); } } diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 1de522d257..8ab2c8815d 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -79,9 +79,6 @@ use tokio::net::{TcpListener, TcpStream}; use tokio::sync::watch; use tracing::{debug, error, info, warn}; -#[cfg(test)] -pub(crate) static TEST_ENV_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); - /// Serializes tests that assert on captured spans, which share one exporter. #[cfg(test)] pub(crate) static TEST_TRACING_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); @@ -2629,3 +2626,7 @@ mod tests { ); } } + +#[cfg(test)] +#[path = "../../../tests/support/environment.rs"] +mod test_environment; diff --git a/crates/openshell-server/src/otel_tracing.rs b/crates/openshell-server/src/otel_tracing.rs index fd2cb05e33..3945126cea 100644 --- a/crates/openshell-server/src/otel_tracing.rs +++ b/crates/openshell-server/src/otel_tracing.rs @@ -451,6 +451,7 @@ pub mod test_exporter { #[cfg(test)] mod tests { use super::*; + use crate::test_environment::Environment; fn config() -> OtlpConfig { OtlpConfig { @@ -465,15 +466,12 @@ mod tests { #[test] fn resource_defaults_the_service_name() { - let _lock = crate::TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _env = EnvVarGuard::remove("OTEL_SERVICE_NAME"); - - assert_eq!( - service_name_of(&build_test_resource(&config())), - Some(DEFAULT_SERVICE_NAME.to_string()) - ); + Environment::new().remove("OTEL_SERVICE_NAME").run(|| { + assert_eq!( + service_name_of(&build_test_resource(&config())), + Some(DEFAULT_SERVICE_NAME.to_string()) + ); + }); } #[test] @@ -521,40 +519,6 @@ mod tests { ); } - struct EnvVarGuard { - key: &'static str, - original: Option, - } - - impl EnvVarGuard { - #[allow(unsafe_code)] - fn remove(key: &'static str) -> Self { - let original = std::env::var(key).ok(); - // SAFETY: tests serialize environment mutation with TEST_ENV_LOCK. - unsafe { std::env::remove_var(key) }; - Self { key, original } - } - - #[allow(unsafe_code)] - fn set(key: &'static str, value: &str) -> Self { - let original = std::env::var(key).ok(); - // SAFETY: tests serialize environment mutation with TEST_ENV_LOCK. - unsafe { std::env::set_var(key, value) }; - Self { key, original } - } - } - - impl Drop for EnvVarGuard { - #[allow(unsafe_code)] - fn drop(&mut self) { - // SAFETY: tests serialize environment mutation with TEST_ENV_LOCK. - match self.original.as_deref() { - Some(value) => unsafe { std::env::set_var(self.key, value) }, - None => unsafe { std::env::remove_var(self.key) }, - } - } - } - fn service_name_of(resource: &Resource) -> Option { resource .get(&opentelemetry::Key::from_static_str("service.name")) @@ -566,48 +530,43 @@ mod tests { /// when an operator has stated it explicitly. #[test] fn configured_service_name_wins_over_the_env_var() { - let _lock = crate::TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _env = EnvVarGuard::set("OTEL_SERVICE_NAME", "from-env"); - - let mut cfg = config(); - cfg.service_name = Some("from-config".into()); + Environment::new() + .set("OTEL_SERVICE_NAME", "from-env") + .run(|| { + let mut cfg = config(); + cfg.service_name = Some("from-config".into()); - assert_eq!( - service_name_of(&build_test_resource(&cfg)), - Some("from-config".to_string()) - ); + assert_eq!( + service_name_of(&build_test_resource(&cfg)), + Some("from-config".to_string()) + ); + }); } /// With no `service_name` in the config file, the SDK's env detector is /// the fallback rather than the built-in default. #[test] fn env_service_name_applies_when_config_omits_it() { - let _lock = crate::TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _env = EnvVarGuard::set("OTEL_SERVICE_NAME", "from-env"); - - assert_eq!( - service_name_of(&build_test_resource(&config())), - Some("from-env".to_string()) - ); + Environment::new() + .set("OTEL_SERVICE_NAME", "from-env") + .run(|| { + assert_eq!( + service_name_of(&build_test_resource(&config())), + Some("from-env".to_string()) + ); + }); } #[test] fn blank_service_name_falls_back_to_the_default() { - let _lock = crate::TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _env = EnvVarGuard::remove("OTEL_SERVICE_NAME"); - - let mut cfg = config(); - cfg.service_name = Some(" ".into()); - assert_eq!( - service_name_of(&build_test_resource(&cfg)), - Some(DEFAULT_SERVICE_NAME.to_string()) - ); + Environment::new().remove("OTEL_SERVICE_NAME").run(|| { + let mut cfg = config(); + cfg.service_name = Some(" ".into()); + assert_eq!( + service_name_of(&build_test_resource(&cfg)), + Some(DEFAULT_SERVICE_NAME.to_string()) + ); + }); } #[test] diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index c093c46ae9..239466358b 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -62,6 +62,7 @@ default = ["bundled-ca-roots"] bundled-ca-roots = ["dep:webpki-roots"] [dev-dependencies] +openshell-driver-vm = { path = "../openshell-driver-vm", default-features = false, features = ["allocation-tracking"] } openshell-ocsf = { path = "../openshell-ocsf", features = ["test-support"] } openshell-otel = { path = "../openshell-otel" } openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } @@ -76,7 +77,11 @@ tokio-stream = { workspace = true, features = ["net"] } [target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(target_os = "linux")'.dependencies] +nix = { workspace = true, features = ["socket", "net"] } + [target.'cfg(unix)'.dev-dependencies] +rustix = { workspace = true } [lints] workspace = true diff --git a/crates/openshell-supervisor-network/README.md b/crates/openshell-supervisor-network/README.md new file mode 100644 index 0000000000..30f404752a --- /dev/null +++ b/crates/openshell-supervisor-network/README.md @@ -0,0 +1,16 @@ +# Network supervisor + +This crate forbids unsafe code, including its local tests. Typed socket APIs +provide original-destination lookup for transparent proxying. + +Socket attribution tests pass a duplicate connected socket as a spawned child's +stdin. Re-executing the test binary checks multiple holders with the same binary +identity; executing a different binary checks ambiguous shared-socket identity. +The fixture kills and reaps the child when the parent test ends. + +The manual proxy performance baseline still counts allocations. Its test-only +global allocator uses `openshell-driver-vm::allocation_tracking`; the unsafe +allocator implementation belongs to the VM crate. The VM dependency disables +default features, so this instrumentation does not +require the compute runtime or Unix-only sandbox library. It is a development +dependency only. diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index 8115e33e09..d0377b5d27 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +#![forbid(unsafe_code)] + //! Networking component of the `OpenShell` supervisor. //! //! Owns the egress proxy, L7 enforcement, OPA policy engine, identity cache, @@ -26,61 +28,10 @@ mod token_grant; pub mod upstream_proxy; #[cfg(test)] -pub(crate) mod test_alloc { - use std::alloc::{GlobalAlloc, Layout, System}; - use std::sync::atomic::{AtomicU64, Ordering}; - - struct CountingAllocator; - - static ALLOCATIONS: AtomicU64 = AtomicU64::new(0); - static ALLOCATED_BYTES: AtomicU64 = AtomicU64::new(0); - - #[allow(unsafe_code)] - unsafe impl GlobalAlloc for CountingAllocator { - unsafe fn alloc(&self, layout: Layout) -> *mut u8 { - let pointer = unsafe { System.alloc(layout) }; - if !pointer.is_null() { - ALLOCATIONS.fetch_add(1, Ordering::Relaxed); - ALLOCATED_BYTES.fetch_add(layout.size() as u64, Ordering::Relaxed); - } - pointer - } - - unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { - let pointer = unsafe { System.alloc_zeroed(layout) }; - if !pointer.is_null() { - ALLOCATIONS.fetch_add(1, Ordering::Relaxed); - ALLOCATED_BYTES.fetch_add(layout.size() as u64, Ordering::Relaxed); - } - pointer - } - - unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) { - unsafe { System.dealloc(pointer, layout) }; - } - - unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { - let pointer = unsafe { System.realloc(pointer, layout, new_size) }; - if !pointer.is_null() { - ALLOCATIONS.fetch_add(1, Ordering::Relaxed); - ALLOCATED_BYTES.fetch_add(new_size as u64, Ordering::Relaxed); - } - pointer - } - } - - #[global_allocator] - static GLOBAL: CountingAllocator = CountingAllocator; - - pub fn reset() { - ALLOCATIONS.store(0, Ordering::SeqCst); - ALLOCATED_BYTES.store(0, Ordering::SeqCst); - } - - pub fn snapshot() -> (u64, u64) { - ( - ALLOCATIONS.load(Ordering::SeqCst), - ALLOCATED_BYTES.load(Ordering::SeqCst), - ) - } -} +pub(crate) use openshell_driver_vm::allocation_tracking as test_alloc; +#[cfg(test)] +#[global_allocator] +static TEST_ALLOCATOR: openshell_driver_vm::allocation_tracking::CountingAllocator = + openshell_driver_vm::allocation_tracking::CountingAllocator; +#[cfg(all(test, target_os = "linux"))] +mod test_support; diff --git a/crates/openshell-supervisor-network/src/procfs.rs b/crates/openshell-supervisor-network/src/procfs.rs index a40f812dc5..839b5de2f5 100644 --- a/crates/openshell-supervisor-network/src/procfs.rs +++ b/crates/openshell-supervisor-network/src/procfs.rs @@ -900,21 +900,10 @@ mod tests { #[cfg(target_os = "linux")] #[test] - fn resolve_tcp_peer_socket_owners_returns_all_forked_socket_holders() { + fn resolve_tcp_peer_socket_owners_returns_all_descendant_socket_holders() { use std::net::{TcpListener, TcpStream}; use std::time::{Duration, Instant}; - struct ChildGuard(libc::pid_t); - impl Drop for ChildGuard { - fn drop(&mut self) { - #[allow(unsafe_code)] - unsafe { - libc::kill(self.0, libc::SIGKILL); - libc::waitpid(self.0, std::ptr::null_mut(), 0); - } - } - } - let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener"); let proxy_addr = listener.local_addr().unwrap(); let stream = TcpStream::connect(proxy_addr).expect("connect"); @@ -922,21 +911,9 @@ mod tests { let connection = WorkloadProxyTcpConnection::new(workload_addr, proxy_addr); let (_accepted, _) = listener.accept().expect("accept"); - // libc/syscall FFI requires unsafe - #[allow(unsafe_code)] - let child_pid = unsafe { libc::fork() }; - assert!(child_pid >= 0, "fork failed"); - if child_pid == 0 { - // libc/syscall FFI requires unsafe - #[allow(unsafe_code)] - unsafe { - libc::sleep(30); - libc::_exit(0); - } - } - - let _guard = ChildGuard(child_pid); - let child_pid_u32 = child_pid.cast_unsigned(); + let guard = crate::test_support::spawn_socket_holder(&stream, false); + let child_pid = guard.0.id(); + let child_pid_u32 = child_pid; let entrypoint_pid = std::process::id(); let deadline = Instant::now() + Duration::from_secs(5); let owners = loop { @@ -952,7 +929,7 @@ mod tests { } assert!( Instant::now() < deadline, - "timed out waiting for forked child to appear as a socket owner; got {owner_pids:?}" + "timed out waiting for descendant child to appear as a socket owner; got {owner_pids:?}" ); std::thread::sleep(Duration::from_millis(20)); }; diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index d44e04497c..fc20dca23c 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -43,8 +43,6 @@ use openshell_ocsf::{ HttpRequest, HttpResponse, NetworkActivityBuilder, Process, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, }; -#[cfg(target_os = "linux")] -use std::mem::size_of; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; @@ -1414,47 +1412,18 @@ fn build_transparent_tcp_allow_ocsf_event( #[cfg(target_os = "linux")] fn original_destination(stream: &TcpStream) -> std::io::Result { - use std::os::fd::AsRawFd; - let fd = stream.as_raw_fd(); + use nix::sys::socket::{getsockopt, sockopt}; + if stream.local_addr()?.is_ipv4() { - #[allow(unsafe_code)] - unsafe { - let mut address: libc::sockaddr_in = std::mem::zeroed(); - let mut length = libc::socklen_t::try_from(size_of::()) - .expect("sockaddr_in size fits socklen_t"); - if libc::getsockopt( - fd, - libc::SOL_IP, - 80, // SO_ORIGINAL_DST - std::ptr::addr_of_mut!(address).cast(), - std::ptr::addr_of_mut!(length), - ) != 0 - { - return Err(std::io::Error::last_os_error()); - } - return Ok(SocketAddr::new( - IpAddr::V4(std::net::Ipv4Addr::from( - address.sin_addr.s_addr.to_ne_bytes(), - )), - u16::from_be(address.sin_port), - )); - } - } - #[allow(unsafe_code)] - unsafe { - let mut address: libc::sockaddr_in6 = std::mem::zeroed(); - let mut length = libc::socklen_t::try_from(size_of::()) - .expect("sockaddr_in6 size fits socklen_t"); - if libc::getsockopt( - fd, - libc::SOL_IPV6, - 80, // IP6T_SO_ORIGINAL_DST - std::ptr::addr_of_mut!(address).cast(), - std::ptr::addr_of_mut!(length), - ) != 0 - { - return Err(std::io::Error::last_os_error()); - } + let address = getsockopt(stream, sockopt::OriginalDst)?; + Ok(SocketAddr::new( + IpAddr::V4(std::net::Ipv4Addr::from( + address.sin_addr.s_addr.to_ne_bytes(), + )), + u16::from_be(address.sin_port), + )) + } else { + let address = getsockopt(stream, sockopt::Ip6tOriginalDst)?; Ok(SocketAddr::new( IpAddr::V6(std::net::Ipv6Addr::from(address.sin6_addr.s6_addr)), u16::from_be(address.sin6_port), @@ -14881,24 +14850,11 @@ network_policies: // binary causes /proc//exe readlink to return ENOENT on // SELinux-enforcing hosts. Fix by building a test-sleep-helper binary in // the same crate so it inherits the user_home_t label. - fn resolve_process_identity_denies_fork_exec_shared_socket_ambiguity() { + fn resolve_process_identity_denies_exec_shared_socket_ambiguity() { use crate::identity::BinaryIdentityCache; - use std::ffi::CString; use std::net::{TcpListener, TcpStream}; - use std::os::fd::AsRawFd; use std::time::{Duration, Instant}; - struct ChildGuard(libc::pid_t); - impl Drop for ChildGuard { - fn drop(&mut self) { - #[allow(unsafe_code)] - unsafe { - libc::kill(self.0, libc::SIGKILL); - libc::waitpid(self.0, std::ptr::null_mut(), 0); - } - } - } - if !std::path::Path::new("/bin/sleep").exists() { eprintln!("skipping: /bin/sleep not available"); return; @@ -14921,41 +14877,8 @@ network_policies: let connection = crate::procfs::WorkloadProxyTcpConnection::new(workload_addr, proxy_addr); let (_accepted, _) = listener.accept().expect("accept"); - let fd = stream.as_raw_fd(); - // libc/syscall FFI requires unsafe - #[allow(unsafe_code)] - unsafe { - let flags = libc::fcntl(fd, libc::F_GETFD); - assert!(flags >= 0, "F_GETFD failed"); - assert_eq!( - libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC), - 0, - "F_SETFD failed" - ); - } - - let sleep_path = CString::new("/bin/sleep").unwrap(); - let arg0 = CString::new("sleep").unwrap(); - let arg1 = CString::new("30").unwrap(); - // libc/syscall FFI requires unsafe - #[allow(unsafe_code)] - let child_pid = unsafe { libc::fork() }; - assert!(child_pid >= 0, "fork failed"); - if child_pid == 0 { - // libc/syscall FFI requires unsafe - #[allow(unsafe_code)] - unsafe { - libc::execl( - sleep_path.as_ptr(), - arg0.as_ptr(), - arg1.as_ptr(), - std::ptr::null::(), - ); - libc::_exit(127); - } - } - - let _guard = ChildGuard(child_pid); + let guard = crate::test_support::spawn_socket_holder(&stream, true); + let child_pid = guard.0.id(); let entrypoint_pid = std::process::id(); let deadline = Instant::now() + Duration::from_secs(5); diff --git a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs index 26649d7a34..a039cfbc14 100644 --- a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs +++ b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs @@ -576,8 +576,15 @@ network_policies: let elapsed = started.elapsed(); let queries = crate::opa::test_opa_query_count(); let (allocations, allocated_bytes) = crate::test_alloc::snapshot(); - let expected_queries = 4; + // One endpoint-policy query precedes the loopback-address + // rejection; this denied path never reaches upstream stages. + let expected_queries = 1; assert_eq!(queries, expected_queries * iterations); + assert!(allocations > 0, "allocation instrumentation is inactive"); + assert!( + allocated_bytes > 0, + "allocated-byte instrumentation is inactive" + ); results.insert( name.to_string(), serde_json::json!({ diff --git a/crates/openshell-supervisor-network/src/test_support.rs b/crates/openshell-supervisor-network/src/test_support.rs new file mode 100644 index 0000000000..2fe79e3eb5 --- /dev/null +++ b/crates/openshell-supervisor-network/src/test_support.rs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Socket-owner subprocess fixtures. + +use std::net::TcpStream; +use std::os::fd::OwnedFd; +use std::process::{Child, Command, Stdio}; + +pub struct SocketHolder(pub Child); + +impl Drop for SocketHolder { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +/// Keep a duplicate of the parent's connected socket in a descendant process. +/// Stdin transfers descriptor ownership through the standard process API. +pub fn spawn_socket_holder(stream: &TcpStream, exec_sleep: bool) -> SocketHolder { + let mut command = if exec_sleep { + let mut command = Command::new("/bin/sleep"); + command.arg("30"); + command + } else { + let mut command = Command::new(std::env::current_exe().expect("test executable")); + command + .args([ + "--exact", + "test_support::socket_holder_child", + "--nocapture", + ]) + .env("OPENSHELL_SOCKET_HOLDER_CHILD", "1"); + command + }; + let fd = OwnedFd::from(stream.try_clone().expect("duplicate socket")); + SocketHolder( + command + .stdin(Stdio::from(fd)) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn socket holder"), + ) +} + +#[test] +fn socket_holder_child() { + if std::env::var_os("OPENSHELL_SOCKET_HOLDER_CHILD").is_some() { + std::thread::sleep(std::time::Duration::from_secs(30)); + } +} diff --git a/crates/openshell-supervisor-network/tests/accept_fd_exhaustion.rs b/crates/openshell-supervisor-network/tests/accept_fd_exhaustion.rs index 3fd09f9c89..eba9ef051f 100644 --- a/crates/openshell-supervisor-network/tests/accept_fd_exhaustion.rs +++ b/crates/openshell-supervisor-network/tests/accept_fd_exhaustion.rs @@ -15,7 +15,6 @@ //! limits are not affected. #![cfg(unix)] -#![allow(unsafe_code, reason = "setrlimit requires unsafe")] use std::env; use std::io::Read; @@ -49,14 +48,14 @@ fn accept_fd_exhaustion_child() { return; } - let limit = libc::rlimit { - rlim_cur: 32, - rlim_max: 32, - }; - assert_eq!( - unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, std::ptr::from_ref(&limit)) }, - 0, - ); + rustix::process::setrlimit( + rustix::process::Resource::Nofile, + rustix::process::Rlimit { + current: Some(32), + maximum: Some(32), + }, + ) + .expect("setrlimit"); let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); let addr = listener.local_addr().unwrap(); diff --git a/crates/openshell-supervisor-process/Cargo.toml b/crates/openshell-supervisor-process/Cargo.toml index a733429f6b..77ce2a9d6f 100644 --- a/crates/openshell-supervisor-process/Cargo.toml +++ b/crates/openshell-supervisor-process/Cargo.toml @@ -35,6 +35,7 @@ uuid = { workspace = true } [target.'cfg(unix)'.dependencies] libc = "0.2" +rustix = { workspace = true, features = ["termios"] } [dev-dependencies] openshell-ocsf = { path = "../openshell-ocsf", features = ["test-support"] } diff --git a/crates/openshell-supervisor-process/src/main_session.rs b/crates/openshell-supervisor-process/src/main_session.rs index 1e59aa5c6e..96598a8cd4 100644 --- a/crates/openshell-supervisor-process/src/main_session.rs +++ b/crates/openshell-supervisor-process/src/main_session.rs @@ -11,7 +11,7 @@ use std::sync::{Arc, Mutex}; use bytes::Bytes; use nix::fcntl::{FcntlArg, OFlag, fcntl}; -use nix::pty::Winsize; +use rustix::termios::Winsize; use tokio::io::unix::AsyncFd; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::Notify; @@ -254,9 +254,7 @@ impl MainSession { #[allow(unsafe_code)] pub fn terminal_size_for_test(&self) -> (u16, u16) { let master = self.pty_master.as_ref().expect("terminal PTY master"); - let mut winsize: libc::winsize = unsafe { std::mem::zeroed() }; - let result = unsafe { libc::ioctl(master.as_raw_fd(), libc::TIOCGWINSZ, &mut winsize) }; - assert_eq!(result, 0, "read terminal dimensions"); + let winsize = rustix::termios::tcgetwinsize(master).expect("read terminal dimensions"); (winsize.ws_col, winsize.ws_row) } @@ -664,10 +662,7 @@ impl MainSession { ws_xpixel: u16::try_from(pixel_width).unwrap_or(u16::MAX), ws_ypixel: u16::try_from(pixel_height).unwrap_or(u16::MAX), }; - #[allow(unsafe_code)] - unsafe { - libc::ioctl(master.as_raw_fd(), libc::TIOCSWINSZ, &winsize); - } + let _ = rustix::termios::tcsetwinsize(master, winsize); } pub async fn signal_group(&self, signal: nix::sys::signal::Signal) -> Result<(), String> { diff --git a/crates/openshell-supervisor/README.md b/crates/openshell-supervisor/README.md index ca3fa73372..4484745f65 100644 --- a/crates/openshell-supervisor/README.md +++ b/crates/openshell-supervisor/README.md @@ -21,3 +21,16 @@ The setup interface stays private to the supervisor. It adds no runtime backend Diagnostics and OCSF shorthand share a bounded, nonblocking stderr writer. With `ocsf_json_enabled=true`, the same writer also receives timestamped `OCSF-JSON` records. Each formatter submits a complete line in one write so concurrent producers cannot interleave records in the queue. The 1,024-line queue drops new lines when full rather than waiting for stderr. Console JSON is installed independently of optional file appenders and uses an INFO filter independent of the diagnostic filter. Its runtime enabled flag and target schema version are shared with the existing JSONL file layer. The supervisor retains the writer guards until shutdown. The gateway log push layer continues to emit shorthand only. + +## Safe runtime and parent liveness + +The supervisor drives the safe isolation-backend contract and network/policy +orchestration. Its library and executable forbid unsafe code. + +For a VM host supervisor, the VM driver passes its parent-liveness pipe as stdin +and sets the private `--parent-liveness-stdin` flag. A dedicated reader terminates +the supervisor when the driver closes its write endpoint or exits. The driver +protects both original pipe endpoints with close-on-exec and lets `Stdio` transfer +the read endpoint to the child's stdin. The supervisor uses the standard stdin +reader and does not adopt a numeric raw descriptor. This mode reserves stdin for +liveness; sandbox workload input travels over the boundary protocol. diff --git a/crates/openshell-supervisor/src/lib.rs b/crates/openshell-supervisor/src/lib.rs index 8d498e162d..7f33e80475 100644 --- a/crates/openshell-supervisor/src/lib.rs +++ b/crates/openshell-supervisor/src/lib.rs @@ -5,6 +5,8 @@ //! //! This crate provides process sandboxing and monitoring capabilities. +#![forbid(unsafe_code)] + // `defaults-without-telemetry` is an alias for the default feature set minus // `telemetry`, not a switch that turns telemetry off. Cargo cannot subtract a // default feature, so adding it on top of the defaults would otherwise produce diff --git a/crates/openshell-supervisor/src/main.rs b/crates/openshell-supervisor/src/main.rs index 0d86964820..7c062b5f2b 100644 --- a/crates/openshell-supervisor/src/main.rs +++ b/crates/openshell-supervisor/src/main.rs @@ -3,6 +3,8 @@ //! `OpenShell` supervisor executable. +#![forbid(unsafe_code)] + use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -134,32 +136,21 @@ struct Args { #[arg(long, hide = true)] main_exit_marker: Option, - /// Read end of a driver-owned pipe. EOF means the owning driver exited. + /// Stdin is a driver-owned liveness pipe. EOF means the driver exited. #[arg(long, hide = true)] - parent_liveness_fd: Option, + parent_liveness_stdin: bool, } #[cfg(unix)] -#[allow(unsafe_code)] -fn arm_parent_liveness(raw_fd: Option) -> Result<()> { +fn arm_parent_liveness(enabled: bool) -> Result<()> { use std::io::Read as _; - use std::os::fd::{FromRawFd as _, OwnedFd}; - - let Some(raw_fd) = raw_fd else { + if !enabled { return Ok(()); - }; - if raw_fd <= 2 { - return Err(miette::miette!("parent liveness descriptor is invalid")); } - nix::fcntl::fcntl(raw_fd, nix::fcntl::FcntlArg::F_GETFD) - .map_err(|error| miette::miette!("parent liveness descriptor is not open: {error}"))?; - // SAFETY: the driver transfers this inherited descriptor to the - // supervisor exactly once through the private command line. - let fd = unsafe { OwnedFd::from_raw_fd(raw_fd) }; std::thread::Builder::new() .name("supervisor-parent-liveness".to_string()) .spawn(move || { - let mut stream = std::fs::File::from(fd); + let mut stream = std::io::stdin().lock(); let mut byte = [0_u8; 1]; loop { match stream.read(&mut byte) { @@ -173,10 +164,10 @@ fn arm_parent_liveness(raw_fd: Option) -> Result<()> { } #[cfg(not(unix))] -fn arm_parent_liveness(raw_fd: Option) -> Result<()> { - if raw_fd.is_some() { +fn arm_parent_liveness(enabled: bool) -> Result<()> { + if enabled { return Err(miette::miette!( - "parent liveness descriptors are unsupported on this platform" + "parent liveness stdin is unsupported on this platform" )); } Ok(()) @@ -246,7 +237,7 @@ fn validate_role_arguments(args: &Args) -> Result<()> { || args.health_socket_path.is_some() || args.health_port.is_some() || args.main_exit_marker.is_some() - || args.parent_liveness_fd.is_some() + || args.parent_liveness_stdin { return Err(miette::miette!( "--role=network-proxy does not use sandbox identity, gateway, runtime, or process-control arguments" @@ -302,7 +293,7 @@ fn main() -> Result<()> { let args = Args::parse(); validate_role_arguments(&args)?; - arm_parent_liveness(args.parent_liveness_fd)?; + arm_parent_liveness(args.parent_liveness_stdin)?; validate_main_exit_marker(args.main_exit_marker.as_deref())?; let isolation_inputs = if args.role == SupervisorRole::IsolationBackend { let descriptor = backend_descriptor(&args)?; @@ -488,6 +479,81 @@ fn otlp_span_filter(log_level: &str) -> EnvFilter { #[cfg(test)] mod tests { + + #[cfg(unix)] + #[test] + fn parent_liveness_stdin_child() { + let Some(marker) = std::env::var_os("OPENSHELL_LIVENESS_TEST_MARKER") else { + return; + }; + arm_parent_liveness(true).unwrap(); + std::fs::write(marker, "ready").unwrap(); + std::thread::sleep(std::time::Duration::from_secs(30)); + panic!("liveness EOF did not terminate the supervisor"); + } + + #[cfg(unix)] + #[test] + fn parent_liveness_exits_only_after_writer_closes() { + use std::io::Write as _; + use std::os::fd::AsRawFd as _; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + struct Probe(std::process::Child); + impl Drop for Probe { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + let dir = tempfile::tempdir().unwrap(); + let marker = dir.path().join("ready"); + let (read, write) = nix::unistd::pipe().unwrap(); + for fd in [&read, &write] { + nix::fcntl::fcntl( + fd.as_raw_fd(), + nix::fcntl::FcntlArg::F_SETFD(nix::fcntl::FdFlag::FD_CLOEXEC), + ) + .unwrap(); + } + let mut writer = std::fs::File::from(write); + let mut probe = Probe( + Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "tests::parent_liveness_stdin_child", + "--nocapture", + ]) + .env("OPENSHELL_LIVENESS_TEST_MARKER", &marker) + .stdin(Stdio::from(read)) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ); + let deadline = Instant::now() + Duration::from_secs(10); + while !marker.exists() { + assert!(probe.0.try_wait().unwrap().is_none()); + assert!(Instant::now() < deadline, "liveness probe did not start"); + std::thread::sleep(Duration::from_millis(10)); + } + writer.write_all(&[1]).unwrap(); + std::thread::sleep(Duration::from_millis(50)); + assert!( + probe.0.try_wait().unwrap().is_none(), + "data must not terminate supervisor" + ); + drop(writer); + loop { + if let Some(status) = probe.0.try_wait().unwrap() { + assert_eq!(status.code(), Some(1)); + break; + } + assert!(Instant::now() < deadline, "liveness EOF was not observed"); + std::thread::sleep(Duration::from_millis(10)); + } + } + use super::*; #[test] diff --git a/tests/support/environment.rs b/tests/support/environment.rs new file mode 100644 index 0000000000..932ca556f4 --- /dev/null +++ b/tests/support/environment.rs @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Run environment-dependent assertions in a fresh copy of the test process. +//! The test is re-entered to rebuild its fixtures; only the selected case runs. +//! Use `fixture_dir` to share captured directory paths across re-entry, or +//! read paths from the child's environment when the parent supplied them. + +use std::cell::Cell; +use std::ffi::OsStr; +use std::future::Future; +use std::pin::Pin; +use std::process::Command; + +const CHILD_CASE: &str = "OPENSHELL_TEST_ENVIRONMENT_CASE"; +const COMPLETED: &str = "OPENSHELL_TEST_ENVIRONMENT_COMPLETED"; + +thread_local! { + static NEXT_CASE: Cell = const { Cell::new(0) }; + static FIXTURE_DIRS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; + static NEXT_DIR: Cell = const { Cell::new(0) }; +} + +#[derive(Default)] +pub struct Environment { + variables: Vec<(String, Option)>, +} + +#[allow(dead_code)] +impl Environment { + pub fn new() -> Self { + Self::default() + } + + pub fn set(mut self, key: impl Into, value: impl AsRef) -> Self { + self.variables + .push((key.into(), Some(value.as_ref().to_owned()))); + self + } + + pub fn remove(mut self, key: impl Into) -> Self { + self.variables.push((key.into(), None)); + self + } + + fn selected(&self) -> bool { + let test = std::thread::current() + .name() + .expect("named test thread") + .to_owned(); + let index = NEXT_CASE.with(|next| { + let index = next.get(); + next.set(index + 1); + index + }); + let case = format!("{test}:{index}"); + if let Some(selected) = std::env::var_os(CHILD_CASE) { + return selected == OsStr::new(&case); + } + + let mut command = Command::new(std::env::current_exe().expect("test executable")); + command.args([ + "--exact", + &test, + "--nocapture", + "--test-threads=1", + "--include-ignored", + ]); + command.env(CHILD_CASE, &case); + FIXTURE_DIRS.with(|dirs| { + for (index, path) in dirs.borrow().iter().enumerate() { + command.env(format!("OPENSHELL_TEST_FIXTURE_DIR_{index}"), path); + } + }); + for (key, value) in &self.variables { + match value { + Some(value) => { + command.env(key, value); + } + None => { + command.env_remove(key); + } + } + } + let output = command.output().expect("spawn environment-isolated test"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "isolated test {case} failed: {}\n{stdout}\n{stderr}", + output.status + ); + // A misspelled filter or an early return must not silently pass. + assert!( + stderr + .lines() + .any(|line| line == format!("{COMPLETED}:{case}")), + "isolated test {case} did not run its assertions:\n{stdout}\n{stderr}" + ); + false + } + + fn completed() { + eprintln!( + "{COMPLETED}:{}", + std::env::var(CHILD_CASE).expect("selected child case") + ); + } + + pub fn from_pairs(pairs: &[(&str, &str)]) -> Self { + pairs + .iter() + .fold(Self::new(), |env, (key, value)| env.set(*key, value)) + } + + pub fn run_async( + self, + assertions: impl Future, + ) -> Pin>> { + let selected = self.selected(); + // Integration-test futures can contain large gateway request state. + // Keep the enclosing test future small by storing the case on the heap. + Box::pin(async move { + if selected { + assertions.await; + Self::completed(); + } + }) + } + + pub fn run(self, assertions: impl FnOnce()) { + if self.selected() { + assertions(); + Self::completed(); + } + } +} + +/// Temporary fixture directory kept alive by the parent while a child re-enters +/// the test. Reusing its path keeps captured fixture paths and child environment +/// values consistent. Directories first created inside the assertions are local. +#[allow(dead_code)] +pub struct FixtureDir { + path: std::path::PathBuf, + _owner: Option, +} + +#[allow(dead_code)] +impl FixtureDir { + pub fn path(&self) -> &std::path::Path { + &self.path + } +} + +#[allow(dead_code)] +pub fn fixture_dir() -> std::io::Result { + let index = NEXT_DIR.with(|next| { + let index = next.get(); + next.set(index + 1); + index + }); + if std::env::var_os(CHILD_CASE).is_some() + && let Some(path) = std::env::var_os(format!("OPENSHELL_TEST_FIXTURE_DIR_{index}")) + { + return Ok(FixtureDir { + path: path.into(), + _owner: None, + }); + } + let owner = tempfile::tempdir()?; + let path = owner.path().to_owned(); + FIXTURE_DIRS.with(|dirs| dirs.borrow_mut().push(path.clone())); + Ok(FixtureDir { + path, + _owner: Some(owner), + }) +} From 77c5ff6e20c98b3674616ecf35b2464f2b8e57ca Mon Sep 17 00:00:00 2001 From: Eric Curtin Date: Mon, 12 Oct 2026 05:50:40 +0000 Subject: [PATCH 05/32] fix(cli): name the sandbox in service not-found errors (#3798) Fixes #3736 Signed-off-by: Eric Curtin --- crates/openshell-cli/src/run.rs | 90 ++++++++++++++++++++++++++++----- 1 file changed, 76 insertions(+), 14 deletions(-) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 02bc0968f0..49f6f05d81 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -4183,14 +4183,10 @@ async fn expose_service_endpoint( )), }) .await - .map_err(service_expose_status_error) + .map_err(|status| service_status_error("expose service", "sandbox:write", sandbox, status)) .map(tonic::Response::into_inner) } -fn service_expose_status_error(status: Status) -> miette::Report { - service_status_error("expose service", "sandbox:write", status) -} - #[allow(clippy::too_many_arguments)] // user-facing CLI command pub async fn service_list( server: &str, @@ -4215,7 +4211,14 @@ pub async fn service_list( }), }) .await - .map_err(|status| service_status_error("list services", "sandbox:read", status))? + .map_err(|status| { + service_status_error( + "list services", + "sandbox:read", + sandbox.unwrap_or_default(), + status, + ) + })? .into_inner(); let next_page_token = response.next_page_token.clone(); @@ -4265,7 +4268,7 @@ pub async fn service_get( name: service.to_string(), }) .await - .map_err(|status| service_status_error("get service", "sandbox:read", status))? + .map_err(|status| service_status_error("get service", "sandbox:read", sandbox, status))? .into_inner(); print_service_endpoint_table(&[response], server, false); @@ -4291,7 +4294,7 @@ pub async fn service_delete( )), }) .await - .map_err(|status| service_status_error("delete service", "sandbox:write", status))? + .map_err(|status| service_status_error("delete service", "sandbox:write", sandbox, status))? .into_inner(); if !deletion_completed(response.outcome)? { @@ -4315,13 +4318,23 @@ pub async fn service_delete( Ok(()) } -fn service_status_error(action: &str, required_scope: &str, status: Status) -> miette::Report { +fn service_status_error( + action: &str, + required_scope: &str, + sandbox: &str, + status: Status, +) -> miette::Report { let message = status.message(); match status.code() { Code::PermissionDenied => { miette!("{action} failed: permission denied (requires {required_scope})") } Code::Unauthenticated => miette!("{action} failed: authentication required"), + Code::NotFound if message == "sandbox not found" && !sandbox.is_empty() => { + miette!( + "{action} failed: sandbox \"{sandbox}\" not found (the first argument is the sandbox name, not the service name)" + ) + } Code::NotFound if message == "sandbox not found" => { miette!("{action} failed: sandbox not found") } @@ -6902,7 +6915,7 @@ mod tests { policy_revision_to_json, proto_execution_timeout, provisioning_timeout_message, ready_false_condition_message, relay_local_socket, resolve_from, rootfs_tar_sources_supported_for_gateway, sandbox_should_persist, sandbox_upload_plan, - service_endpoint_to_json, service_expose_status_error, service_url_for_gateway, + service_endpoint_to_json, service_status_error, service_url_for_gateway, workspace_member_to_json, }; use openshell_core::proto::TcpForwardFrame; @@ -7847,10 +7860,13 @@ mod tests { } #[test] - fn service_expose_status_error_mentions_required_scope() { - let report = service_expose_status_error(Status::permission_denied( - "scope 'sandbox:write' required", - )); + fn service_status_error_mentions_required_scope() { + let report = service_status_error( + "expose service", + "sandbox:write", + "demo", + Status::permission_denied("scope 'sandbox:write' required"), + ); assert_eq!( report.to_string(), @@ -7858,6 +7874,52 @@ mod tests { ); } + #[test] + fn service_status_error_names_missing_sandbox() { + let report = service_status_error( + "delete service", + "sandbox:write", + "web-http", + Status::not_found("sandbox not found"), + ); + + assert_eq!( + report.to_string(), + "delete service failed: sandbox \"web-http\" not found \ + (the first argument is the sandbox name, not the service name)" + ); + } + + #[test] + fn service_status_error_omits_empty_sandbox_name() { + let report = service_status_error( + "list services", + "sandbox:read", + "", + Status::not_found("sandbox not found"), + ); + + assert_eq!( + report.to_string(), + "list services failed: sandbox not found" + ); + } + + #[test] + fn service_status_error_keeps_missing_service_message() { + let report = service_status_error( + "get service", + "sandbox:read", + "demo", + Status::not_found("service endpoint not found"), + ); + + assert_eq!( + report.to_string(), + "get service failed: service endpoint not found" + ); + } + #[test] fn ready_false_condition_message_prefers_reason_and_message() { let status = SandboxStatus { From ca187123dd807f5e0f1cbeae64dcbf32245c296f Mon Sep 17 00:00:00 2001 From: Krzysztof Malczuk Date: Mon, 12 Oct 2026 11:14:31 +0000 Subject: [PATCH 06/32] feat(gateway): add gRPC server reflection (#3161) * feat(gateway): add gRPC server reflection Signed-off-by: Krzysztof Malczuk * fix(gateway): rate limit gRPC reflection queries Signed-off-by: Krzysztof Malczuk * fix(gateway): comply with gRPC reflection protocol Signed-off-by: Krzysztof Malczuk * fix(gateway): complete reflection symbol lookups Signed-off-by: Krzysztof Malczuk --------- Signed-off-by: Krzysztof Malczuk --- Cargo.lock | 15 + Cargo.toml | 1 + README.md | 17 + crates/openshell-server/Cargo.toml | 1 + crates/openshell-server/src/auth/oidc.rs | 10 +- crates/openshell-server/src/lib.rs | 69 +++ crates/openshell-server/src/multiplex.rs | 539 +++++++++++++++++- crates/openshell-server/src/reflection.rs | 449 +++++++++++++++ docs/how-it-works/gateways/authentication.mdx | 33 ++ docs/how-it-works/gateways/configuration.mdx | 1 + 10 files changed, 1129 insertions(+), 6 deletions(-) create mode 100644 crates/openshell-server/src/reflection.rs diff --git a/Cargo.lock b/Cargo.lock index 407213cf88..414e242b9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4977,6 +4977,7 @@ dependencies = [ "toml", "tonic", "tonic-prost-build", + "tonic-reflection", "tower", "tower-http", "tracing", @@ -8276,6 +8277,20 @@ dependencies = [ "tonic-build", ] +[[package]] +name = "tonic-reflection" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acccd136a4bf19810a1fde9c74edc6129b42a66b44d0c1c8aaa67aeb49a146a7" +dependencies = [ + "prost", + "prost-types", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", +] + [[package]] name = "tonic-types" version = "0.14.6" diff --git a/Cargo.toml b/Cargo.toml index c6136bdc26..56f9e8b58c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ tokio = { version = "1.43", features = ["full"] } # gRPC/Protobuf tonic = "0.14" tonic-types = "0.14" +tonic-reflection = "0.14" tonic-prost = "0.14" tonic-prost-build = "0.14" prost = "0.14" diff --git a/README.md b/README.md index e0aaa43641..43f887bb95 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,23 @@ The installer sets up the CLI and a local gateway. The default sandbox image is - [Tutorials](https://docs.nvidia.com/openshell/latest/tutorials/first-network-policy): step-by-step policy and agent walkthroughs. - [Prerelease and development builds](https://docs.nvidia.com/openshell/latest/about/installation#prerelease-and-development-builds): try an upcoming release or the latest commit on `main`. +### Test the gRPC API with grpcurl + +The gateway serves the gRPC reflection v1 protocol. After starting a local +plaintext gateway, use `grpcurl` without checking out or supplying the proto +files: + +```shell +grpcurl -plaintext localhost:18080 list +grpcurl -plaintext localhost:18080 describe openshell.v1.OpenShell +grpcurl -plaintext -d '{}' localhost:18080 openshell.v1.OpenShell/Health +``` + +The service list contains the public `openshell.v1.OpenShell` API. Reflection +does not advertise the gateway's internal compute-driver, credential-driver, +interceptor, or middleware services. For a TLS gateway, omit `-plaintext` and +supply the CA and client certificate options required by the deployment. + ## Agent Skills Install the public OpenShell skills for your coding agent: diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 0b34c233b7..d3bed5daff 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -39,6 +39,7 @@ libc = "0.2" # gRPC tonic = { workspace = true, features = ["channel", "tls-native-roots"] } +tonic-reflection = { workspace = true } prost = { workspace = true } prost-reflect = { workspace = true } prost-types = { workspace = true } diff --git a/crates/openshell-server/src/auth/oidc.rs b/crates/openshell-server/src/auth/oidc.rs index eb02f1ef7d..5e876826b0 100644 --- a/crates/openshell-server/src/auth/oidc.rs +++ b/crates/openshell-server/src/auth/oidc.rs @@ -32,7 +32,8 @@ use tracing::{debug, error, info, warn}; /// These are structural bypasses for gRPC infrastructure that doesn't map to a /// single RPC method. Per-method bypasses (e.g. `Health`) are declared at the /// handler with `auth_mode: "unauthenticated"` in the proto annotation. -const UNAUTHENTICATED_PREFIXES: &[&str] = &["/grpc.reflection.", "/grpc.health."]; +const UNAUTHENTICATED_PREFIXES: &[&str] = + &[crate::multiplex::REFLECTION_PATH_PREFIX, "/grpc.health."]; /// Returns `true` if the method needs no authentication at all. pub fn is_unauthenticated_method(path: &str) -> bool { @@ -1205,10 +1206,13 @@ mod tests { #[test] fn reflection_is_unauthenticated() { assert!(is_unauthenticated_method( + "/grpc.reflection.v1.ServerReflection/ServerReflectionInfo" + )); + assert!(!is_unauthenticated_method( "/grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo" )); - assert!(is_unauthenticated_method( - "/grpc.reflection.v1.ServerReflection/ServerReflectionInfo" + assert!(!is_unauthenticated_method( + "/grpc.reflection.v2.ServerReflection/ServerReflectionInfo" )); } diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 8ab2c8815d..da6a74c216 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -38,6 +38,7 @@ pub(crate) mod policy_store; mod provider_profile_sources; mod provider_refresh; mod readiness; +mod reflection; mod sandbox_index; mod sandbox_watch; mod service_routing; @@ -349,6 +350,9 @@ pub struct ServerState { /// Gateway-wide gRPC request rate limiter shared by every multiplex path. pub(crate) grpc_rate_limiter: Option, + /// Immutable public reflection index and its per-query rate limiter. + pub(crate) reflection_service: reflection::GatewayReflectionServer, + /// Per-sandbox bound on extension credential minting, which resolves the /// caller's effective policy on every request. pub(crate) extension_mint_limiter: auth::extension_mint_limit::ExtensionMintLimiter, @@ -426,6 +430,8 @@ impl ServerState { compute.configure_ssh_identities(credentials.clone()); let peer_endpoint = derive_peer_endpoint(&config); let grpc_rate_limiter = multiplex::GrpcRateLimiter::from_config(&config); + let reflection_service = reflection::build_gateway_reflection_service(&config) + .expect("compiled public gateway descriptors must be valid"); let admin_role = config .oidc .as_ref() @@ -458,6 +464,7 @@ impl ServerState { compute_driver_authenticator: None, peer_authenticator: None, grpc_rate_limiter, + reflection_service, gateway_interceptors: None, provider_profile_sources: provider_profile_sources::ProviderProfileSources::with_default_sources(), @@ -2219,6 +2226,68 @@ mod tests { (listen_addr, shutdown_tx, handle, tls_dir) } + async fn start_plaintext_gateway_listener() + -> (SocketAddr, watch::Sender, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind test listener"); + let listen_addr = listener.local_addr().expect("failed to read local addr"); + let state = test_state(listen_addr, false).await; + let service = MultiplexService::new(state); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let handle = tokio::spawn(serve_gateway_listener( + BoundGatewayListener { + listener, + address: listen_addr, + }, + service, + None, + false, + shutdown_rx, + )); + (listen_addr, shutdown_tx, handle) + } + + #[tokio::test] + async fn production_gateway_listener_serves_reflection() { + use tonic_reflection::pb::v1::{ + ServerReflectionRequest, server_reflection_client::ServerReflectionClient, + server_reflection_request::MessageRequest, server_reflection_response::MessageResponse, + }; + + let (addr, shutdown, handle) = start_plaintext_gateway_listener().await; + let channel = tonic::transport::Channel::from_shared(format!("http://{addr}")) + .unwrap() + .connect() + .await + .unwrap(); + let mut client = ServerReflectionClient::new(channel); + let request = ServerReflectionRequest { + host: String::new(), + message_request: Some(MessageRequest::ListServices(String::new())), + }; + let mut responses = client + .server_reflection_info(tokio_stream::iter([request])) + .await + .unwrap() + .into_inner(); + let response = responses.message().await.unwrap().unwrap(); + let Some(MessageResponse::ListServicesResponse(services)) = response.message_response + else { + panic!("expected a reflection list-services response"); + }; + assert_eq!( + services + .service + .iter() + .map(|service| service.name.as_str()) + .collect::>(), + ["openshell.v1.OpenShell"] + ); + + stop_listener(shutdown, handle).await; + } + async fn send_plain_http(addr: SocketAddr, request: String) -> String { let connect_addr: SocketAddr = format!("127.0.0.1:{}", addr.port()) .parse() diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index a383f75c4a..d1b26220b4 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -215,7 +215,6 @@ macro_rules! request_id_middleware { /// the largest payload and well within this cap under normal use. const MAX_GRPC_DECODE_SIZE: usize = 1_048_576; const MAX_INTERCEPTED_GRPC_BODY_SIZE: usize = MAX_GRPC_DECODE_SIZE + 5; - /// Concurrent HTTP/2 streams allowed per connection. Sits above the /// per-replica pending relay budget so pooled peer connections are bounded by /// the relay caps rather than by the transport. @@ -288,6 +287,7 @@ impl MultiplexService { self.state.gateway_interceptors.clone(), Some(self.state.clone()), ); + let reflection = self.state.reflection_service.clone(); let authz_policy = self.state.config.oidc.as_ref().map(|oidc| AuthzPolicy { admin_role: oidc.admin_role.clone(), user_role: oidc.user_role.clone(), @@ -295,7 +295,7 @@ impl MultiplexService { }); let authenticator_chain = build_authenticator_chain(&self.state); let grpc_service = AuthGrpcRouter::with_peer_identity( - openshell, + GrpcRouter::new(openshell, reflection), authenticator_chain, authz_policy, self.state @@ -790,7 +790,7 @@ impl GrpcRateLimiter { }) } - fn allow(&self) -> bool { + pub(crate) fn allow(&self) -> bool { let now = Instant::now(); let mut state = self .state @@ -911,6 +911,56 @@ where } } +/// Combined gRPC service that routes between `OpenShell` and reflection. +#[derive(Clone)] +pub struct GrpcRouter { + openshell: N, + reflection: R, +} + +impl GrpcRouter { + fn new(openshell: N, reflection: R) -> Self { + Self { + openshell, + reflection, + } + } +} + +pub const REFLECTION_PATH_PREFIX: &str = "/grpc.reflection.v1."; + +impl tower::Service> for GrpcRouter +where + N: tower::Service> + Clone + Send + 'static, + N::Response: Send, + N::Future: Send, + N::Error: Send, + R: tower::Service, Response = N::Response, Error = N::Error> + + Clone + + Send + + 'static, + R::Future: Send, + B: Send + 'static, +{ + type Response = N::Response; + type Error = N::Error; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, req: Request) -> Self::Future { + if req.uri().path().starts_with(REFLECTION_PATH_PREFIX) { + let mut svc = self.reflection.clone(); + Box::pin(async move { svc.ready().await?.call(req).await }) + } else { + let mut svc = self.openshell.clone(); + Box::pin(async move { svc.ready().await?.call(req).await }) + } + } +} + /// Assemble the authenticator chain for the gateway. /// /// Chain order (first-match-wins): @@ -2653,6 +2703,464 @@ mod tests { assert_eq!(grpc_method_from_path(""), ""); } + #[tokio::test] + async fn grpc_router_dispatches_gateway_and_reflection_paths() { + #[derive(Clone)] + struct RouteRecorder { + name: &'static str, + calls: Arc>>, + } + + impl Service> for RouteRecorder { + type Response = Response; + type Error = Infallible; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, _req: Request) -> Self::Future { + self.calls.lock().unwrap().push(self.name); + Box::pin(async { Ok(Response::new(tonic::body::Body::empty())) }) + } + } + + let calls = Arc::new(Mutex::new(Vec::new())); + let service = |name| RouteRecorder { + name, + calls: calls.clone(), + }; + let mut router = GrpcRouter::new(service("openshell"), service("reflection")); + + for path in [ + "/openshell.v1.OpenShell/Health", + "/grpc.reflection.v1.ServerReflection/ServerReflectionInfo", + ] { + router + .call( + Request::builder() + .uri(path) + .body(Empty::::new()) + .unwrap(), + ) + .await + .unwrap(); + } + + assert_eq!(*calls.lock().unwrap(), vec!["openshell", "reflection"]); + } + + #[tokio::test] + async fn reflection_protocol_serves_complete_public_descriptors_and_recovers_from_errors() { + use crate::auth::authenticator::test_support::MockAuthenticator; + use prost_reflect::{DescriptorPool, Value}; + use tonic_reflection::pb::v1::{ + ServerReflectionRequest, server_reflection_client::ServerReflectionClient, + server_reflection_request::MessageRequest, server_reflection_response::MessageResponse, + }; + + let reflection = + crate::reflection::build_gateway_reflection_service(&Config::new(None)).unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let unrouted = tower::service_fn(|_request: Request| async { + Ok::<_, Infallible>(tonic::Status::unimplemented("test fallback").into_http()) + }); + let rejecting_oidc = Arc::new(MockAuthenticator::returning(Err( + tonic::Status::unauthenticated("OIDC credentials required"), + ))); + let grpc = AuthGrpcRouter::with_peer_identity( + GrpcRouter::new(unrouted, reflection), + Some(AuthenticatorChain::new(vec![rejecting_oidc])), + None, + None, + true, + false, + ); + let service = MultiplexedService::new(grpc, unrouted); + let server = tokio::spawn(async move { + loop { + let (stream, _) = listener.accept().await.unwrap(); + let service = service.clone(); + tokio::spawn(async move { + Builder::new(TokioExecutor::new()) + .serve_connection(TokioIo::new(stream), service) + .await + .unwrap(); + }); + } + }); + + let channel = tonic::transport::Channel::from_shared(format!("http://{addr}")) + .unwrap() + .connect() + .await + .unwrap(); + let mut client = ServerReflectionClient::new(channel); + let list_request = ServerReflectionRequest { + host: String::new(), + message_request: Some(MessageRequest::ListServices(String::new())), + }; + let descriptor_request = ServerReflectionRequest { + host: String::new(), + message_request: Some(MessageRequest::FileContainingSymbol( + "openshell.v1.OpenShell".to_string(), + )), + }; + let missing_request = ServerReflectionRequest { + host: String::new(), + message_request: Some(MessageRequest::FileContainingSymbol( + "openshell.v1.DoesNotExist".to_string(), + )), + }; + let list_after_error_request = ServerReflectionRequest { + host: String::new(), + message_request: Some(MessageRequest::ListServices(String::new())), + }; + let mut responses = client + .server_reflection_info(tokio_stream::iter([ + list_request, + descriptor_request, + missing_request, + list_after_error_request, + ])) + .await + .unwrap() + .into_inner(); + let response = responses.message().await.unwrap().unwrap(); + let Some(MessageResponse::ListServicesResponse(response)) = response.message_response + else { + panic!("expected a reflection list-services response"); + }; + let mut names: Vec<_> = response + .service + .into_iter() + .map(|service| service.name) + .collect(); + names.sort(); + + assert_eq!(names, vec!["openshell.v1.OpenShell"]); + + let descriptor_response = responses.message().await.unwrap().unwrap(); + let Some(MessageResponse::FileDescriptorResponse(response)) = + descriptor_response.message_response + else { + panic!("expected a reflection file-descriptor response"); + }; + let mut reflected_pool = DescriptorPool::new(); + for descriptor in &response.file_descriptor_proto { + reflected_pool + .decode_file_descriptor_proto(descriptor.as_slice()) + .unwrap(); + } + assert!( + reflected_pool + .get_service_by_name("openshell.v1.OpenShell") + .is_some(), + "a fresh descriptor pool must resolve the advertised service" + ); + assert!( + reflected_pool + .get_message_by_name("openshell.v1.HealthRequest") + .is_some(), + "the response must include imported public message descriptors" + ); + + let source_pool = DescriptorPool::decode(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); + let source_auth = source_pool + .get_extension_by_name("openshell.options.v1.authorization") + .unwrap(); + let reflected_auth = reflected_pool + .get_extension_by_name("openshell.options.v1.authorization") + .unwrap(); + let source_health = source_pool + .get_service_by_name("openshell.v1.OpenShell") + .unwrap() + .methods() + .find(|method| method.name() == "Health") + .unwrap(); + let reflected_health = reflected_pool + .get_service_by_name("openshell.v1.OpenShell") + .unwrap() + .methods() + .find(|method| method.name() == "Health") + .unwrap(); + let source_options = source_health.options(); + let reflected_options = reflected_health.options(); + let Value::Message(source_auth_value) = &*source_options.get_extension(&source_auth) else { + panic!("source authorization option must be a message"); + }; + let Value::Message(reflected_auth_value) = + &*reflected_options.get_extension(&reflected_auth) + else { + panic!("reflected authorization option must be a message"); + }; + assert_eq!( + source_auth_value.encode_to_vec(), + reflected_auth_value.encode_to_vec() + ); + + let source_secret = source_pool + .get_extension_by_name("openshell.options.v1.secret") + .unwrap(); + let reflected_secret = reflected_pool + .get_extension_by_name("openshell.options.v1.secret") + .unwrap(); + let source_field = source_pool + .get_message_by_name("openshell.v1.TcpForwardInit") + .unwrap() + .get_field_by_name("authorization_token") + .unwrap(); + let reflected_field = reflected_pool + .get_message_by_name("openshell.v1.TcpForwardInit") + .unwrap() + .get_field_by_name("authorization_token") + .unwrap(); + assert_eq!( + source_field.options().get_extension(&source_secret), + reflected_field.options().get_extension(&reflected_secret), + "secret-field annotations must retain their original value" + ); + + let error_response = responses.message().await.unwrap().unwrap(); + let Some(MessageResponse::ErrorResponse(error)) = error_response.message_response else { + panic!("expected an in-band reflection error response"); + }; + assert_eq!(error.error_code, tonic::Code::NotFound as i32); + + let response_after_error = responses.message().await.unwrap().unwrap(); + assert!(matches!( + response_after_error.message_response, + Some(MessageResponse::ListServicesResponse(_)) + )); + server.abort(); + } + + #[tokio::test] + async fn reflection_protocol_resolves_canonical_enum_values_and_extensions() { + use prost::Message; + use tonic_reflection::pb::v1::{ + ExtensionRequest, ServerReflectionRequest, + server_reflection_client::ServerReflectionClient, + server_reflection_request::MessageRequest, server_reflection_response::MessageResponse, + }; + + let reflection = + crate::reflection::build_gateway_reflection_service(&Config::new(None)).unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let unrouted = tower::service_fn(|_request: Request| async { + Ok::<_, Infallible>(tonic::Status::unimplemented("test fallback").into_http()) + }); + let service = MultiplexedService::new(GrpcRouter::new(unrouted, reflection), unrouted); + let server = tokio::spawn(async move { + loop { + let (stream, _) = listener.accept().await.unwrap(); + let service = service.clone(); + tokio::spawn(async move { + Builder::new(TokioExecutor::new()) + .serve_connection(TokioIo::new(stream), service) + .await + .unwrap(); + }); + } + }); + + let channel = tonic::transport::Channel::from_shared(format!("http://{addr}")) + .unwrap() + .connect() + .await + .unwrap(); + let mut client = ServerReflectionClient::new(channel); + + for (symbol, expected_file) in [ + ("openshell.v1.SANDBOX_PHASE_READY", "openshell.proto"), + ( + "google.protobuf.FieldDescriptorProto.TYPE_DOUBLE", + "google/protobuf/descriptor.proto", + ), + ] { + let request = ServerReflectionRequest { + host: String::new(), + message_request: Some(MessageRequest::FileContainingSymbol(symbol.to_string())), + }; + let mut responses = client + .server_reflection_info(tokio_stream::iter([request])) + .await + .unwrap() + .into_inner(); + let response = responses.message().await.unwrap().unwrap(); + let Some(MessageResponse::FileDescriptorResponse(response)) = response.message_response + else { + panic!("expected a descriptor response for canonical enum value {symbol}"); + }; + let names: std::collections::BTreeSet<_> = response + .file_descriptor_proto + .iter() + .map(|descriptor| { + prost_types::FileDescriptorProto::decode(descriptor.as_slice()) + .unwrap() + .name + .unwrap() + }) + .collect(); + assert!(names.contains(expected_file)); + } + + for (containing_type, extension_number) in [ + ("google.protobuf.MethodOptions", 50_000), + ("google.protobuf.FieldOptions", 50_001), + ] { + let request = ServerReflectionRequest { + host: String::new(), + message_request: Some(MessageRequest::FileContainingExtension(ExtensionRequest { + containing_type: containing_type.to_string(), + extension_number, + })), + }; + let mut responses = client + .server_reflection_info(tokio_stream::iter([request])) + .await + .unwrap() + .into_inner(); + let response = responses.message().await.unwrap().unwrap(); + let Some(MessageResponse::FileDescriptorResponse(response)) = response.message_response + else { + panic!("expected a descriptor response for extension {extension_number}"); + }; + let names: std::collections::BTreeSet<_> = response + .file_descriptor_proto + .iter() + .map(|descriptor| { + prost_types::FileDescriptorProto::decode(descriptor.as_slice()) + .unwrap() + .name + .unwrap() + }) + .collect(); + assert!(names.contains("options.proto")); + } + + let requests = [ + ServerReflectionRequest { + host: String::new(), + message_request: Some(MessageRequest::AllExtensionNumbersOfType( + "google.protobuf.MethodOptions".to_string(), + )), + }, + ServerReflectionRequest { + host: String::new(), + message_request: Some(MessageRequest::FileContainingExtension(ExtensionRequest { + containing_type: "google.protobuf.MethodOptions".to_string(), + extension_number: 49_999, + })), + }, + ServerReflectionRequest { + host: String::new(), + message_request: Some(MessageRequest::ListServices(String::new())), + }, + ]; + let mut responses = client + .server_reflection_info(tokio_stream::iter(requests)) + .await + .unwrap() + .into_inner(); + let extension_numbers = responses.message().await.unwrap().unwrap(); + let Some(MessageResponse::AllExtensionNumbersResponse(extension_numbers)) = + extension_numbers.message_response + else { + panic!("expected an extension-number response"); + }; + assert_eq!( + extension_numbers.base_type_name, + "google.protobuf.MethodOptions" + ); + assert_eq!(extension_numbers.extension_number, [50_000]); + + let unknown_extension = responses.message().await.unwrap().unwrap(); + let Some(MessageResponse::ErrorResponse(error)) = unknown_extension.message_response else { + panic!("expected an in-band error for an unknown extension"); + }; + assert_eq!(error.error_code, tonic::Code::NotFound as i32); + assert!(matches!( + responses.message().await.unwrap().unwrap().message_response, + Some(MessageResponse::ListServicesResponse(_)) + )); + + server.abort(); + } + + #[tokio::test] + async fn reflection_rate_limit_charges_each_query_on_one_stream() { + use tonic::Code; + use tonic_reflection::pb::v1::{ + ServerReflectionRequest, server_reflection_client::ServerReflectionClient, + server_reflection_request::MessageRequest, + }; + + let config = Config::new(None).with_grpc_rate_limit(Some(1), Some(60)); + let reflection = crate::reflection::build_gateway_reflection_service(&config).unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let unrouted = tower::service_fn(|_request: Request| async { + Ok::<_, Infallible>(tonic::Status::unimplemented("test fallback").into_http()) + }); + let service = MultiplexedService::new(GrpcRouter::new(unrouted, reflection), unrouted); + let server = tokio::spawn(async move { + loop { + let (stream, _) = listener.accept().await.unwrap(); + let service = service.clone(); + tokio::spawn(async move { + Builder::new(TokioExecutor::new()) + .serve_connection(TokioIo::new(stream), service) + .await + .unwrap(); + }); + } + }); + + let channel = tonic::transport::Channel::from_shared(format!("http://{addr}")) + .unwrap() + .connect() + .await + .unwrap(); + let mut client = ServerReflectionClient::new(channel); + let query = ServerReflectionRequest { + host: String::new(), + message_request: Some(MessageRequest::ListServices(String::new())), + }; + let mut responses = client + .server_reflection_info(tokio_stream::iter([query.clone(), query])) + .await + .unwrap() + .into_inner(); + + assert!(responses.message().await.unwrap().is_some()); + let status = responses + .message() + .await + .expect_err("second query on the same stream must be rate limited"); + assert_eq!(status.code(), Code::ResourceExhausted); + server.abort(); + } + + #[test] + fn reflection_descriptor_excludes_internal_service_protos() { + let descriptors = crate::reflection::gateway_reflection_descriptors().unwrap(); + let names: std::collections::BTreeSet<_> = descriptors + .iter() + .map(prost_reflect::FileDescriptor::name) + .collect(); + + assert!(names.contains("openshell.proto")); + assert!(names.contains("sandbox.proto")); + assert!(!names.contains("compute_driver.proto")); + assert!(!names.contains("credential_driver.proto")); + assert!(!names.contains("gateway_interceptor.proto")); + assert!(!names.contains("supervisor_middleware.proto")); + } + #[test] fn normalize_ws_tunnel() { assert_eq!(normalize_http_path("/_ws_tunnel"), "/_ws_tunnel"); @@ -2988,6 +3496,31 @@ mod tests { assert_eq!(grpc_status(&res).as_deref(), Some("16")); } + #[tokio::test] + async fn reflection_bypasses_oidc_and_mtls_user_authentication() { + let oidc = Arc::new(MockAuthenticator::returning(Err( + tonic::Status::unauthenticated("OIDC credentials required"), + ))); + let chain = AuthenticatorChain::new(vec![oidc]); + let (recorder, seen) = PrincipalRecorder::new(); + let mut router = + AuthGrpcRouter::with_peer_identity(recorder, Some(chain), None, None, true, false); + + let res = router + .call(empty_request( + "/grpc.reflection.v1.ServerReflection/ServerReflectionInfo", + )) + .await + .unwrap(); + + assert_eq!(res.status(), 200); + assert_eq!(grpc_status(&res), None); + assert!( + seen.lock().unwrap().is_none(), + "reflection must not receive an authenticated user principal" + ); + } + #[tokio::test] async fn unauthenticated_dev_user_fills_missing_principal_when_enabled() { let mock = Arc::new(MockAuthenticator::returning(Ok(None))); diff --git a/crates/openshell-server/src/reflection.rs b/crates/openshell-server/src/reflection.rs new file mode 100644 index 0000000000..140b53659e --- /dev/null +++ b/crates/openshell-server/src/reflection.rs @@ -0,0 +1,449 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Public gateway gRPC reflection service. + +use std::collections::{BTreeSet, HashMap}; +use std::sync::Arc; + +use prost_reflect::{DescriptorError, DescriptorPool, FileDescriptor}; +use prost_types::{ + DescriptorProto, EnumDescriptorProto, FieldDescriptorProto, FileDescriptorProto, +}; +use tokio::sync::mpsc; +use tokio_stream::{Stream, StreamExt}; +use tonic::{Request, Response, Status, Streaming}; +use tonic_reflection::pb::v1::server_reflection_request::MessageRequest; +use tonic_reflection::pb::v1::server_reflection_response::MessageResponse; +use tonic_reflection::pb::v1::server_reflection_server::{ + ServerReflection, ServerReflectionServer, +}; +use tonic_reflection::pb::v1::{ + ErrorResponse, ExtensionNumberResponse, FileDescriptorResponse, ListServiceResponse, + ServerReflectionRequest, ServerReflectionResponse, ServiceResponse, +}; + +use openshell_core::Config; + +use crate::multiplex::GrpcRateLimiter; + +const REFLECTED_PROTO_ROOTS: &[&str] = &["openshell.proto"]; +const ADVERTISED_SERVICES: &[&str] = &["openshell.v1.OpenShell"]; + +pub type GatewayReflectionServer = ServerReflectionServer; + +/// Decode and filter the compiled descriptors to the public gateway schema. +pub fn gateway_reflection_descriptors() -> Result, DescriptorError> { + let descriptor_pool = DescriptorPool::decode(openshell_core::FILE_DESCRIPTOR_SET)?; + let mut included: BTreeSet = REFLECTED_PROTO_ROOTS + .iter() + .map(|name| (*name).to_string()) + .collect(); + + loop { + let before = included.len(); + for file in descriptor_pool.files() { + if included.contains(file.name()) { + included.extend( + file.dependencies() + .map(|dependency| dependency.name().to_string()), + ); + } + } + if included.len() == before { + break; + } + } + + Ok(descriptor_pool + .files() + .filter(|file| included.contains(file.name())) + .collect()) +} + +/// Build the immutable reflection index once at gateway service startup. +pub fn build_gateway_reflection_service( + config: &Config, +) -> Result { + let mut descriptors = gateway_reflection_descriptors()?; + descriptors + .extend(DescriptorPool::decode(tonic_reflection::pb::v1::FILE_DESCRIPTOR_SET)?.files()); + + let state = ReflectionState::new(descriptors); + Ok(ServerReflectionServer::new(GatewayReflectionService { + state: Arc::new(state), + limiter: GrpcRateLimiter::from_config(config), + })) +} + +#[derive(Debug)] +struct ReflectionState { + files: HashMap>, + symbols: HashMap>, + extensions: HashMap<(String, i32), Arc>, + extension_numbers: HashMap>, +} + +#[derive(Debug)] +struct ReflectionFile { + descriptor: FileDescriptorProto, + encoded: Vec, +} + +impl ReflectionState { + fn new(descriptors: Vec) -> Self { + let mut state = Self { + files: HashMap::new(), + symbols: HashMap::new(), + extensions: HashMap::new(), + extension_numbers: HashMap::new(), + }; + for descriptor in descriptors { + let name = descriptor.name().to_string(); + let descriptor = Arc::new(ReflectionFile { + descriptor: descriptor.file_descriptor_proto().clone(), + encoded: descriptor.encode_to_vec(), + }); + state.process_file(descriptor.clone()); + state.files.insert(name, descriptor); + } + state + } + + fn process_file(&mut self, file: Arc) { + let prefix = file + .descriptor + .package + .as_deref() + .unwrap_or_default() + .to_string(); + for message in &file.descriptor.message_type { + self.process_message(file.clone(), &prefix, message); + } + for enumeration in &file.descriptor.enum_type { + self.process_enum(file.clone(), &prefix, enumeration); + } + for extension in &file.descriptor.extension { + self.process_extension(file.clone(), &prefix, extension); + } + for service in &file.descriptor.service { + let Some(name) = service.name.as_deref() else { + continue; + }; + let service_name = qualified_name(&prefix, name); + self.symbols.insert(service_name.clone(), file.clone()); + for method in &service.method { + if let Some(name) = method.name.as_deref() { + self.symbols + .insert(qualified_name(&service_name, name), file.clone()); + } + } + } + } + + fn process_message( + &mut self, + file: Arc, + prefix: &str, + message: &DescriptorProto, + ) { + let Some(name) = message.name.as_deref() else { + return; + }; + let message_name = qualified_name(prefix, name); + self.symbols.insert(message_name.clone(), file.clone()); + for nested in &message.nested_type { + self.process_message(file.clone(), &message_name, nested); + } + for enumeration in &message.enum_type { + self.process_enum(file.clone(), &message_name, enumeration); + } + for extension in &message.extension { + self.process_extension(file.clone(), &message_name, extension); + } + for field in &message.field { + if let Some(name) = field.name.as_deref() { + self.symbols + .insert(qualified_name(&message_name, name), file.clone()); + } + } + for oneof in &message.oneof_decl { + if let Some(name) = oneof.name.as_deref() { + self.symbols + .insert(qualified_name(&message_name, name), file.clone()); + } + } + } + + fn process_enum( + &mut self, + file: Arc, + prefix: &str, + enumeration: &EnumDescriptorProto, + ) { + let Some(name) = enumeration.name.as_deref() else { + return; + }; + let enum_name = qualified_name(prefix, name); + self.symbols.insert(enum_name, file.clone()); + for value in &enumeration.value { + if let Some(name) = value.name.as_deref() { + self.symbols + .insert(qualified_name(prefix, name), file.clone()); + } + } + } + + fn process_extension( + &mut self, + file: Arc, + prefix: &str, + extension: &FieldDescriptorProto, + ) { + if let Some(name) = extension.name.as_deref() { + self.symbols + .insert(qualified_name(prefix, name), file.clone()); + } + + let (Some(containing_type), Some(number)) = + (extension.extendee.as_deref(), extension.number) + else { + return; + }; + let containing_type = normalize_type_name(containing_type).to_string(); + self.extensions + .insert((containing_type.clone(), number), file); + self.extension_numbers + .entry(containing_type) + .or_default() + .insert(number); + } + + fn file_by_name(&self, name: &str) -> Result, Status> { + self.files + .get(name) + .cloned() + .ok_or_else(|| Status::not_found(format!("file '{name}' not found"))) + } + + fn file_by_symbol(&self, symbol: &str) -> Result, Status> { + self.symbols + .get(symbol) + .cloned() + .ok_or_else(|| Status::not_found(format!("symbol '{symbol}' not found"))) + } + + fn file_by_extension( + &self, + containing_type: &str, + extension_number: i32, + ) -> Result, Status> { + let containing_type = normalize_type_name(containing_type); + self.extensions + .get(&(containing_type.to_string(), extension_number)) + .cloned() + .ok_or_else(|| { + Status::not_found(format!( + "extension '{containing_type}:{extension_number}' not found" + )) + }) + } + + fn all_extension_numbers(&self, containing_type: &str) -> Result, Status> { + let containing_type = normalize_type_name(containing_type); + if !self.symbols.contains_key(containing_type) { + return Err(Status::not_found(format!( + "type '{containing_type}' not found" + ))); + } + Ok(self + .extension_numbers + .get(containing_type) + .map_or_else(Vec::new, |numbers| numbers.iter().copied().collect())) + } + + fn file_with_dependencies( + &self, + file: Arc, + sent: &mut BTreeSet, + ) -> Result>, Status> { + let mut descriptors = Vec::new(); + self.collect_file_with_dependencies(file, sent, &mut descriptors)?; + Ok(descriptors) + } + + fn collect_file_with_dependencies( + &self, + file: Arc, + sent: &mut BTreeSet, + descriptors: &mut Vec>, + ) -> Result<(), Status> { + let name = file + .descriptor + .name + .as_deref() + .ok_or_else(|| Status::internal("reflection descriptor is missing its filename"))?; + if !sent.insert(name.to_string()) { + return Ok(()); + } + + for dependency in &file.descriptor.dependency { + let dependency = self.files.get(dependency).cloned().ok_or_else(|| { + Status::internal(format!( + "reflection descriptor '{name}' has unavailable dependency '{dependency}'" + )) + })?; + self.collect_file_with_dependencies(dependency, sent, descriptors)?; + } + descriptors.push(file.encoded.clone()); + Ok(()) + } +} + +fn qualified_name(prefix: &str, name: &str) -> String { + if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}.{name}") + } +} + +fn normalize_type_name(name: &str) -> &str { + name.strip_prefix('.').unwrap_or(name) +} + +/// Reflection implementation with a quota charged for every stream message. +#[derive(Clone, Debug)] +pub struct GatewayReflectionService { + state: Arc, + limiter: Option, +} + +#[tonic::async_trait] +impl ServerReflection for GatewayReflectionService { + type ServerReflectionInfoStream = ReflectionResponseStream; + + async fn server_reflection_info( + &self, + request: Request>, + ) -> Result, Status> { + let mut requests = request.into_inner(); + let (responses_tx, responses_rx) = mpsc::channel(1); + let state = self.state.clone(); + let limiter = self.limiter.clone(); + + tokio::spawn(async move { + let mut sent_descriptors = BTreeSet::new(); + while let Some(request) = requests.next().await { + let Ok(request) = request else { + return; + }; + if limiter.as_ref().is_some_and(|limiter| !limiter.allow()) { + let _ = responses_tx + .send(Err(Status::resource_exhausted( + "gRPC reflection query rate limit exceeded", + ))) + .await; + return; + } + + let response = match request.message_request.as_ref() { + Some(MessageRequest::FileByFilename(name)) => state + .file_by_name(name) + .and_then(|descriptor| { + state.file_with_dependencies(descriptor, &mut sent_descriptors) + }) + .map(|descriptors| { + MessageResponse::FileDescriptorResponse(FileDescriptorResponse { + file_descriptor_proto: descriptors, + }) + }), + Some(MessageRequest::FileContainingSymbol(symbol)) => state + .file_by_symbol(symbol) + .and_then(|descriptor| { + state.file_with_dependencies(descriptor, &mut sent_descriptors) + }) + .map(|descriptors| { + MessageResponse::FileDescriptorResponse(FileDescriptorResponse { + file_descriptor_proto: descriptors, + }) + }), + Some(MessageRequest::FileContainingExtension(extension)) => state + .file_by_extension(&extension.containing_type, extension.extension_number) + .and_then(|descriptor| { + state.file_with_dependencies(descriptor, &mut sent_descriptors) + }) + .map(|descriptors| { + MessageResponse::FileDescriptorResponse(FileDescriptorResponse { + file_descriptor_proto: descriptors, + }) + }), + Some(MessageRequest::AllExtensionNumbersOfType(containing_type)) => state + .all_extension_numbers(containing_type) + .map(|extension_number| { + MessageResponse::AllExtensionNumbersResponse(ExtensionNumberResponse { + base_type_name: normalize_type_name(containing_type).to_string(), + extension_number, + }) + }), + Some(MessageRequest::ListServices(_)) => { + Ok(MessageResponse::ListServicesResponse(ListServiceResponse { + service: ADVERTISED_SERVICES + .iter() + .map(|name| ServiceResponse { + name: (*name).to_string(), + }) + .collect(), + })) + } + None => Err(Status::invalid_argument("invalid MessageRequest")), + }; + + match response { + Ok(message_response) => { + let response = ServerReflectionResponse { + valid_host: request.host.clone(), + original_request: Some(request), + message_response: Some(message_response), + }; + if responses_tx.send(Ok(response)).await.is_err() { + return; + } + } + Err(status) => { + let response = ServerReflectionResponse { + valid_host: request.host.clone(), + original_request: Some(request), + message_response: Some(MessageResponse::ErrorResponse(ErrorResponse { + error_code: status.code() as i32, + error_message: status.message().to_string(), + })), + }; + if responses_tx.send(Ok(response)).await.is_err() { + return; + } + } + } + } + }); + + Ok(Response::new(ReflectionResponseStream { + inner: tokio_stream::wrappers::ReceiverStream::new(responses_rx), + })) + } +} + +pub struct ReflectionResponseStream { + inner: tokio_stream::wrappers::ReceiverStream>, +} + +impl Stream for ReflectionResponseStream { + type Item = Result; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(&mut self.inner).poll_next(cx) + } +} diff --git a/docs/how-it-works/gateways/authentication.mdx b/docs/how-it-works/gateways/authentication.mdx index 6b05e42be9..b764d3fbfe 100644 --- a/docs/how-it-works/gateways/authentication.mdx +++ b/docs/how-it-works/gateways/authentication.mdx @@ -95,6 +95,39 @@ setting. See [Providers](/how-it-works/providers/overview#retrieve-runtime-credentials) for the credential retrieval contract. +### Inspect the API with grpcurl + +The primary gateway listener serves the gRPC reflection v1 protocol. Reflection +does not require application authentication, but the listener's TLS and client +certificate requirements still apply. + +For a local plaintext development gateway: + +```shell +grpcurl -plaintext localhost:18080 list +grpcurl -plaintext localhost:18080 describe openshell.v1.OpenShell +grpcurl -plaintext -d '{}' localhost:18080 openshell.v1.OpenShell/Health +``` + +For an mTLS gateway, use the bundle associated with the gateway: + +```shell +grpcurl \ + -cacert ~/.config/openshell/gateways//mtls/ca.crt \ + -cert ~/.config/openshell/gateways//mtls/tls.crt \ + -key ~/.config/openshell/gateways//mtls/tls.key \ + : list +``` + +Reflection advertises only `openshell.v1.OpenShell`. It does not advertise +internal driver, interceptor, or middleware services. + +When the gateway gRPC rate limit is enabled, reflection uses a separate counter +with the same request count and window. Opening the reflection RPC consumes one +gateway-wide request, and every query sent through that stream consumes one +reflection counter unit. Exceeding the reflection quota closes the stream with +`RESOURCE_EXHAUSTED`. + ### OIDC Gateways can validate OpenID Connect access tokens on gRPC requests. Configure OIDC when you want users or automation to authenticate with an identity provider such as Keycloak, Entra ID, or Okta. diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index 21eda0a59d..d3910f9fd3 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -200,6 +200,7 @@ disable_tls = false guest_tls_ca = "/etc/openshell/certs/ca.pem" # Optional gRPC rate limit. Both values must be positive to enable the limit. +# Reflection queries use a separate counter with the same count and window. # Set either value to 0, or omit both, to disable rate limiting. grpc_rate_limit_requests = 120 grpc_rate_limit_window_seconds = 60 From c4dc30dc1b01839a765361ed07fcde89397a258c Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Mon, 12 Oct 2026 15:35:03 +0000 Subject: [PATCH 07/32] test(conformance): run driver suites with cargo (#3866) * test(conformance): run driver suites with cargo Signed-off-by: Evan Lezar * fix(e2e): resolve conformance CLI path before cargo tests Signed-off-by: Evan Lezar * refactor(conformance): remove unused CLI scenario APIs Signed-off-by: Evan Lezar --------- Signed-off-by: Evan Lezar --- .github/actions/setup-e2e-cli/action.yml | 30 -- .github/workflows/branch-e2e.yml | 14 - .github/workflows/build-binaries.yml | 16 - .github/workflows/e2e-docker-test.yml | 6 - .github/workflows/e2e-gpu-test.yaml | 7 - .github/workflows/e2e-kubernetes-test.yml | 7 - .github/workflows/e2e-podman-test.yml | 6 - .github/workflows/e2e-vm-test.yml | 6 - .github/workflows/release-dev.yml | 2 - .github/workflows/release-tag.yml | 2 - AGENTS.md | 1 - Cargo.lock | 11 - TESTING.md | 39 ++- crates/openshell-conformance-cli/Cargo.toml | 25 -- crates/openshell-conformance-cli/src/main.rs | 273 ------------------ crates/openshell-conformance/src/lib.rs | 36 +-- .../src/scenarios/file_transfer.rs | 18 -- .../src/scenarios/mod.rs | 4 +- .../src/scenarios/policy_behavior.rs | 3 - .../src/scenarios/sandbox_lifecycle.rs | 1 - .../src/scenarios/smoke.rs | 1 - e2e/policy-advisor/README.md | 24 +- e2e/rust/e2e-docker.sh | 2 +- e2e/rust/e2e-kubernetes.sh | 2 +- e2e/support/conformance.sh | 44 ++- nix/test-guest/README.md | 7 +- tasks/test.toml | 75 ++--- tests/artifacts.nix | 6 +- 28 files changed, 97 insertions(+), 571 deletions(-) delete mode 100644 crates/openshell-conformance-cli/Cargo.toml delete mode 100644 crates/openshell-conformance-cli/src/main.rs diff --git a/.github/actions/setup-e2e-cli/action.yml b/.github/actions/setup-e2e-cli/action.yml index d7d1c8a296..8b30970d8d 100644 --- a/.github/actions/setup-e2e-cli/action.yml +++ b/.github/actions/setup-e2e-cli/action.yml @@ -1,12 +1,6 @@ name: Setup E2E CLI description: Download architecture-matched prebuilt OpenShell host CLIs for E2E tests -inputs: - conformance-artifact-prefix: - description: Optional conformance CLI artifact name prefix; linux- is appended automatically - required: false - default: "" - runs: using: composite steps: @@ -23,27 +17,3 @@ runs: chmod +x "$cli" "$cli" --version echo "OPENSHELL_BIN=$cli" >> "$GITHUB_ENV" - - - name: Download prebuilt conformance CLI - if: inputs.conformance-artifact-prefix != '' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: ${{ format('{0}-{1}', inputs.conformance-artifact-prefix, runner.arch == 'X64' && 'x86_64-unknown-linux-musl' || 'aarch64-unknown-linux-musl') }} - path: .e2e/prebuilt-conformance - - - name: Configure prebuilt conformance CLI - if: inputs.conformance-artifact-prefix != '' - shell: bash - run: | # zizmor: ignore[github-env] validated filename under the trusted workspace path - set -euo pipefail - conformance="$GITHUB_WORKSPACE/.e2e/prebuilt-conformance/openshell-conformance" - if [[ ! -f "$conformance" ]]; then - echo "downloaded artifact is missing $conformance" >&2 - exit 1 - fi - # TODO: Remove this temporary mode diagnostic after CI confirms artifact permission handling. - echo "conformance binary mode before chmod: $(ls -l "$conformance")" - chmod +x "$conformance" - echo "conformance binary mode after chmod: $(ls -l "$conformance")" - "$conformance" list --output json >/dev/null - echo "OPENSHELL_CONFORMANCE_BIN=$conformance" >> "$GITHUB_ENV" diff --git a/.github/workflows/branch-e2e.yml b/.github/workflows/branch-e2e.yml index d46f3fb31f..43ad23a628 100644 --- a/.github/workflows/branch-e2e.yml +++ b/.github/workflows/branch-e2e.yml @@ -310,7 +310,6 @@ jobs: with: image-tag: ${{ github.sha }} runner: linux-arm64-cpu8 - conformance-artifact-prefix: openshell-conformance podman-e2e: needs: [pr_metadata, build-binaries, build-images] @@ -322,7 +321,6 @@ jobs: uses: ./.github/workflows/e2e-podman-test.yml with: image-tag: ${{ github.sha }} - conformance-artifact-prefix: openshell-conformance suite-matrix: >- [{"suite":"ci","runner":"ubuntu-26.04","podman_major":"5","podman_package_version":"5.7.0+ds2-3build1","conmon_package_version":"2.1.13+ds1-2","cmd":"mise run --no-deps --skip-deps e2e:podman:ci"}] @@ -334,8 +332,6 @@ jobs: contents: read packages: read uses: ./.github/workflows/e2e-vm-test.yml - with: - conformance-artifact-prefix: openshell-conformance docker-external-driver-e2e: needs: [pr_metadata, build-binaries, build-gateway-plain, build-external-drivers, build-images] @@ -350,7 +346,6 @@ jobs: runner: linux-arm64-cpu8 gateway-artifact: openshell-gateway-plain-aarch64-unknown-linux-gnu external-driver-binary: openshell-driver-docker - conformance-artifact-prefix: openshell-conformance suite-matrix: >- [{"suite":"external-driver","cmd":"mise run --no-deps --skip-deps e2e:docker:external-driver","apt_packages":"openssh-client","python_proto":false,"mcp":false}] @@ -366,7 +361,6 @@ jobs: image-tag: ${{ github.sha }} gateway-artifact: openshell-gateway-plain-x86_64-unknown-linux-gnu external-driver-binary: openshell-driver-podman - conformance-artifact-prefix: openshell-conformance suite-matrix: >- [{"suite":"external-driver","runner":"ubuntu-26.04","podman_major":"5","podman_package_version":"5.7.0+ds2-3build1","conmon_package_version":"2.1.13+ds1-2","cmd":"mise run --no-deps --skip-deps e2e:podman:external-driver"}] @@ -382,7 +376,6 @@ jobs: gateway-artifact: openshell-gateway-plain-x86_64-unknown-linux-gnu suite-name: external-driver e2e-task: e2e:vm:external-driver - conformance-artifact-prefix: openshell-conformance gpu-e2e: needs: [pr_metadata, build-binaries, build-images] @@ -394,7 +387,6 @@ jobs: uses: ./.github/workflows/e2e-gpu-test.yaml with: image-tag: ${{ github.sha }} - conformance-artifact-prefix: openshell-conformance kubernetes-e2e: needs: [pr_metadata, build-binaries, build-images] @@ -416,7 +408,6 @@ jobs: image-tag: ${{ github.sha }} job-name: Kubernetes E2E (Rust smoke, Agent Sandbox ${{ matrix.agent_sandbox_api }}) agent-sandbox-version: ${{ matrix.agent_sandbox_version }} - conformance-artifact-prefix: openshell-conformance kubernetes-workspace-managed-e2e: needs: [pr_metadata, build-binaries, build-images] @@ -430,7 +421,6 @@ jobs: image-tag: ${{ github.sha }} job-name: Kubernetes E2E (workspace managed mode) e2e-task: e2e:kubernetes:workspace-managed - conformance-artifact-prefix: openshell-conformance kubernetes-external-driver-e2e: needs: [pr_metadata, build-binaries, build-gateway-plain, build-external-drivers, build-images] @@ -447,7 +437,6 @@ jobs: gateway-artifact: openshell-gateway-plain-x86_64-unknown-linux-gnu external-driver-binary: openshell-driver-kubernetes cluster-images: sandbox supervisor - conformance-artifact-prefix: openshell-conformance kubernetes-workspace-operator-e2e: needs: [pr_metadata, build-binaries, build-images] @@ -461,7 +450,6 @@ jobs: image-tag: ${{ github.sha }} job-name: Kubernetes E2E (workspace operator mode) e2e-task: e2e:kubernetes:workspace-operator - conformance-artifact-prefix: openshell-conformance kubernetes-ha-e2e: needs: [pr_metadata, build-binaries, build-images] @@ -479,7 +467,6 @@ jobs: test-name: kubernetes_ha_rebalancing kubernetes-features: e2e,e2e-host-gateway,e2e-kubernetes,e2e-kubernetes-ha use-envoy-gateway: true - conformance-artifact-prefix: openshell-conformance kubernetes-credential-drivers-e2e: needs: [pr_metadata, build-binaries, build-images] @@ -493,7 +480,6 @@ jobs: image-tag: ${{ github.sha }} job-name: Kubernetes Credential Drivers E2E e2e-task: e2e:kubernetes:credential-drivers - conformance-artifact-prefix: openshell-conformance core-e2e-result: name: Core E2E result diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 82ef374939..1289968d14 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -62,22 +62,6 @@ jobs: supervisor-image-tag: ${{ inputs.supervisor-image-tag }} extra-cargo-flags: ${{ env.EXTRA_CARGO_FLAGS }} - conformance: - name: openshell-conformance (${{ matrix.triple }}) - env: - PACKAGE: openshell-conformance-cli - BINARY: openshell-conformance - strategy: - matrix: - include: - - triple: x86_64-unknown-linux-musl - runner: linux-amd64-cpu8 - - triple: aarch64-unknown-linux-musl - runner: linux-arm64-cpu8 - runs-on: ${{ matrix.runner }} - timeout-minutes: 60 - steps: *build_steps - prover: name: openshell-prover (${{ matrix.triple }}) env: diff --git a/.github/workflows/e2e-docker-test.yml b/.github/workflows/e2e-docker-test.yml index f26c905d0a..6b20baa8df 100644 --- a/.github/workflows/e2e-docker-test.yml +++ b/.github/workflows/e2e-docker-test.yml @@ -25,10 +25,6 @@ on: required: false type: string default: "" - conformance-artifact-prefix: - required: false - type: string - default: "" suite-matrix: required: false type: string @@ -71,8 +67,6 @@ jobs: persist-credentials: false - uses: ./.github/actions/setup-e2e-cli - with: - conformance-artifact-prefix: ${{ inputs.conformance-artifact-prefix }} - uses: ./.github/actions/setup-e2e-gateway with: diff --git a/.github/workflows/e2e-gpu-test.yaml b/.github/workflows/e2e-gpu-test.yaml index dfc54598fe..d332ae41f7 100644 --- a/.github/workflows/e2e-gpu-test.yaml +++ b/.github/workflows/e2e-gpu-test.yaml @@ -7,11 +7,6 @@ on: description: "Image tag to test (typically the commit SHA)" required: true type: string - conformance-artifact-prefix: - description: "Optional prebuilt conformance CLI artifact prefix (artifact suffix is )" - required: false - type: string - default: "" permissions: actions: read @@ -66,8 +61,6 @@ jobs: - name: Use prebuilt OpenShell CLI uses: ./.github/actions/setup-e2e-cli - with: - conformance-artifact-prefix: ${{ inputs.conformance-artifact-prefix }} - name: Use prebuilt OpenShell gateway uses: ./.github/actions/setup-e2e-gateway diff --git a/.github/workflows/e2e-kubernetes-test.yml b/.github/workflows/e2e-kubernetes-test.yml index 0d018187a8..449f3c25f0 100644 --- a/.github/workflows/e2e-kubernetes-test.yml +++ b/.github/workflows/e2e-kubernetes-test.yml @@ -67,11 +67,6 @@ on: required: false type: string default: "" - conformance-artifact-prefix: - description: "Optional prebuilt conformance CLI artifact prefix (artifact suffix is )" - required: false - type: string - default: "" external-driver-binary: description: "Optional standalone compute driver binary used to compose a local test image" required: false @@ -112,8 +107,6 @@ jobs: - name: Use prebuilt OpenShell CLI uses: ./.github/actions/setup-e2e-cli - with: - conformance-artifact-prefix: ${{ inputs.conformance-artifact-prefix }} - name: Use prebuilt OpenShell gateway if: inputs.gateway-artifact != '' diff --git a/.github/workflows/e2e-podman-test.yml b/.github/workflows/e2e-podman-test.yml index 4ca40da39c..d70ad6c60c 100644 --- a/.github/workflows/e2e-podman-test.yml +++ b/.github/workflows/e2e-podman-test.yml @@ -21,10 +21,6 @@ on: required: false type: string default: "" - conformance-artifact-prefix: - required: false - type: string - default: "" suite-matrix: required: true type: string @@ -56,8 +52,6 @@ jobs: persist-credentials: false - uses: ./.github/actions/setup-e2e-cli - with: - conformance-artifact-prefix: ${{ inputs.conformance-artifact-prefix }} - uses: ./.github/actions/setup-e2e-gateway with: diff --git a/.github/workflows/e2e-vm-test.yml b/.github/workflows/e2e-vm-test.yml index 954de9669e..57e04c32b3 100644 --- a/.github/workflows/e2e-vm-test.yml +++ b/.github/workflows/e2e-vm-test.yml @@ -22,10 +22,6 @@ on: required: false type: string default: e2e:vm - conformance-artifact-prefix: - required: false - type: string - default: "" permissions: actions: read @@ -47,8 +43,6 @@ jobs: persist-credentials: false - uses: ./.github/actions/setup-e2e-cli - with: - conformance-artifact-prefix: ${{ inputs.conformance-artifact-prefix }} - uses: ./.github/actions/setup-e2e-gateway with: diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 0da5d1e186..4e129cf200 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -159,7 +159,6 @@ jobs: image-tag: ${{ github.sha }} checkout-ref: ${{ github.sha }} runner: linux-arm64-cpu8 - conformance-artifact-prefix: openshell-conformance vm-e2e: needs: [build-binaries, build-vm-driver] @@ -170,7 +169,6 @@ jobs: uses: ./.github/workflows/e2e-vm-test.yml with: checkout-ref: ${{ github.sha }} - conformance-artifact-prefix: openshell-conformance tag-ghcr-dev: name: Tag GHCR Images as Dev diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 7a225270a6..817b34a0c4 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -210,7 +210,6 @@ jobs: image-tag: ${{ needs.compute-versions.outputs.source_sha }} checkout-ref: ${{ needs.compute-versions.outputs.source_sha }} runner: linux-arm64-cpu8 - conformance-artifact-prefix: openshell-conformance vm-e2e: needs: [compute-versions, build-binaries, build-vm-driver] @@ -221,7 +220,6 @@ jobs: uses: ./.github/workflows/e2e-vm-test.yml with: checkout-ref: ${{ needs.compute-versions.outputs.source_sha }} - conformance-artifact-prefix: openshell-conformance protobuf-compatibility: name: Protobuf Compatibility diff --git a/AGENTS.md b/AGENTS.md index 083785987f..4ac21136a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,6 @@ Do not rely on this file for a full inventory. The detailed public and contribut |------|-----------|---------| | `crates/openshell-cli/` | CLI binary | User-facing command-line interface | | `crates/openshell-conformance/` | CLI conformance library | Reusable driver-agnostic scenarios and command runner | -| `crates/openshell-conformance-cli/` | Conformance CLI | Legacy local `list` and `run` entrypoint pending follow-up cleanup | | `crates/openshell-server/` | Gateway server | Control-plane API, sandbox lifecycle, auth boundary | | `crates/openshell-sandbox/` | Sandbox runtime | Capability-free workload launcher, process identity, and seccomp-mediated I/O | | `crates/openshell-supervisor/` | Supervisor runtime | Gateway session, policy evaluation, credentials, and upstream networking | diff --git a/Cargo.lock b/Cargo.lock index 414e242b9e..6a2c214e2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4306,17 +4306,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "openshell-conformance-cli" -version = "0.0.0" -dependencies = [ - "clap", - "openshell-conformance", - "serde", - "serde_json", - "tokio", -] - [[package]] name = "openshell-core" version = "0.0.0" diff --git a/TESTING.md b/TESTING.md index a1c2f9476e..570d135f5b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -213,11 +213,12 @@ HTTP `Host`, TLS SNI, and mTLS handling. Suites: - Common suite (`--features e2e`) - driver-neutral CLI behavior, sandbox lifecycle, sync, port forwarding, policy, and provider tests. -- CLI conformance (`openshell-conformance`) - named scenarios for lifecycle, - mechanistic drafts, and the sandbox-local API, including agent-authored - permission requests. Driver E2E wrappers run every scenario. The - installed-artifact conformance suite runs all scenarios and offers a focused - `policy-advisor` testsuite for manual integration runs. +- CLI conformance (`tests/suites/conformance`) - portable Cargo tests for + lifecycle, mechanistic drafts, file transfer, and the sandbox-local API, + including agent-authored permission requests. Driver E2E runs the complete + Cargo test package. The installed-artifact conformance suite runs the same + tests from a nextest archive and offers a focused `policy-advisor` testsuite + for manual integration runs. - Driver suites (`--features e2e-docker`, `e2e-podman`, `e2e-kubernetes`, or `e2e-vm`) - CLI conformance plus the common and driver-specific coverage for the selected deployment. @@ -242,17 +243,25 @@ Run the Docker-backed Rust CLI e2e suite: mise run e2e:docker ``` -Run the minimal portable CLI conformance profile against the gateway selected -in your OpenShell CLI configuration: +Run the portable CLI conformance suite against the gateway selected in your +OpenShell CLI configuration: ```shell -mise run e2e:cli-conformance +cargo build --package openshell-cli +OPENSHELL_BIN="$PWD/target/debug/openshell" \ + cargo test \ + --locked \ + --manifest-path tests/suites/conformance/Cargo.toml \ + --package openshell-test-conformance-cli \ + --no-fail-fast \ + -- \ + --test-threads=1 \ + --nocapture ``` -The gateway must already be installed, reachable, and selected before the task -starts. The task does not provision a gateway or select a compute driver. Set -`OPENSHELL_BIN` to test a prebuilt CLI; otherwise, the task builds the CLI from -the current checkout. +The gateway must already be installed, reachable, and selected before the tests +start. The test suite does not provision a gateway or select a compute driver. +Set `OPENSHELL_BIN` to another executable to test a different prebuilt CLI. 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 @@ -278,9 +287,9 @@ openshell sandbox list --output json openshell sandbox delete ``` -Gateway-backed Rust E2E tasks build the standalone conformance CLI, run its -registered scenarios against the configured gateway, then run any lane-specific -Rust tests that still apply. Run the Podman-backed Rust CLI e2e suite: +Gateway-backed Rust E2E tasks run the CLI conformance Cargo tests against the +configured gateway, then run any lane-specific Rust tests that still apply. +Run the Podman-backed Rust CLI e2e suite: ```shell mise run e2e:podman diff --git a/crates/openshell-conformance-cli/Cargo.toml b/crates/openshell-conformance-cli/Cargo.toml deleted file mode 100644 index ce9f89c17c..0000000000 --- a/crates/openshell-conformance-cli/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -[package] -name = "openshell-conformance-cli" -description = "Standalone OpenShell CLI conformance test runner" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true - -[[bin]] -name = "openshell-conformance" -path = "src/main.rs" - -[dependencies] -clap.workspace = true -openshell-conformance = { path = "../openshell-conformance" } -serde.workspace = true -serde_json.workspace = true -tokio.workspace = true - -[lints] -workspace = true diff --git a/crates/openshell-conformance-cli/src/main.rs b/crates/openshell-conformance-cli/src/main.rs deleted file mode 100644 index 81ae17257b..0000000000 --- a/crates/openshell-conformance-cli/src/main.rs +++ /dev/null @@ -1,273 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Standalone runner for `OpenShell` CLI conformance scenarios. - -use std::path::PathBuf; -use std::process::ExitCode; - -use clap::{Parser, Subcommand, ValueEnum}; -use openshell_conformance::{OpenShellRunner, Scenario, scenario, scenarios}; -use serde::Serialize; - -#[derive(Debug, Parser)] -#[command( - name = "openshell-conformance", - about = "Run OpenShell CLI conformance scenarios", - version -)] -struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Debug, Subcommand)] -enum Command { - /// List registered scenarios. - List { - #[arg(long, value_enum, default_value_t = OutputFormat::Text)] - output: OutputFormat, - }, - /// Run all registered scenarios, or named scenarios. - Run { - /// Scenario names. Omit to run every registered scenario. - scenarios: Vec, - /// Explicit path to the `OpenShell` CLI. Defaults to `openshell` on PATH. - #[arg(long)] - openshell_bin: Option, - #[arg(long, value_enum, default_value_t = OutputFormat::Text)] - output: OutputFormat, - }, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] -enum OutputFormat { - Text, - Json, -} - -#[derive(Serialize)] -struct ScenarioDescription<'a> { - name: &'a str, - description: &'a str, -} - -#[derive(Serialize)] -struct ScenarioResult<'a> { - name: &'a str, - passed: bool, - diagnostic: Option, -} - -#[derive(Serialize)] -struct RunReport<'a> { - scenarios: Vec>, - passed: bool, -} - -#[tokio::main] -async fn main() -> ExitCode { - match execute(Cli::parse()).await { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - eprintln!("openshell-conformance: {error}"); - ExitCode::FAILURE - } - } -} - -async fn execute(cli: Cli) -> Result<(), String> { - match cli.command { - Command::List { output } => list(output), - Command::Run { - scenarios: requested, - openshell_bin, - output, - } => run(&requested, openshell_bin, output).await, - } -} - -fn list(output: OutputFormat) -> Result<(), String> { - match output { - OutputFormat::Text => { - for candidate in scenarios() { - println!("{:<24} {}", candidate.name, candidate.description); - } - } - OutputFormat::Json => { - let result = scenarios() - .iter() - .map(|candidate| ScenarioDescription { - name: candidate.name, - description: candidate.description, - }) - .collect::>(); - println!( - "{}", - serde_json::to_string_pretty(&result).map_err(|error| error.to_string())? - ); - } - } - Ok(()) -} - -async fn run( - requested: &[String], - binary: Option, - output: OutputFormat, -) -> Result<(), String> { - let selected = select_scenarios(requested)?; - let mut results = Vec::with_capacity(selected.len()); - for candidate in selected { - results.push(run_scenario(candidate, binary.as_ref()).await); - } - - render_results(results, output) -} - -async fn run_scenario( - candidate: &'static Scenario, - binary: Option<&PathBuf>, -) -> ScenarioResult<'static> { - let runner = binary.map_or_else( - || OpenShellRunner::new(candidate.name), - |path| OpenShellRunner::with_binary(path.clone(), candidate.name), - ); - let mut runner = match runner { - Ok(runner) => runner, - Err(error) => { - return ScenarioResult { - name: candidate.name, - passed: false, - diagnostic: Some(error.to_string()), - }; - } - }; - eprintln!("CLI conformance run ID: {}", runner.id()); - let scenario_result = match runner.check_gateway_status().await { - Ok(()) => candidate.run(&mut runner).await, - Err(error) => Err(error), - }; - let outcome = runner.finish(scenario_result).await; - ScenarioResult { - name: candidate.name, - passed: outcome.is_ok(), - diagnostic: outcome.err(), - } -} - -fn render_results( - results: Vec>, - output: OutputFormat, -) -> Result<(), String> { - let passed = results.iter().all(|result| result.passed); - match output { - OutputFormat::Text => { - for result in &results { - if result.passed { - println!("PASS {}", result.name); - } else { - println!( - "FAIL {}\n{}", - result.name, - result.diagnostic.as_deref().unwrap_or("unknown failure") - ); - } - } - } - OutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&RunReport { - scenarios: results, - passed - }) - .map_err(|error| error.to_string())? - ), - } - if passed { - Ok(()) - } else { - Err("one or more scenarios failed".to_string()) - } -} - -fn select_scenarios(requested: &[String]) -> Result, String> { - if requested.is_empty() { - return Ok(scenarios().iter().collect()); - } - requested - .iter() - .map(|name| { - scenario(name).ok_or_else(|| { - format!("unknown scenario '{name}'; run `openshell-conformance list`") - }) - }) - .collect() -} - -#[cfg(test)] -mod tests { - use clap::Parser; - - use super::*; - - #[test] - fn selects_all_scenarios_by_default() { - assert_eq!( - select_scenarios(&[]).expect("select all").len(), - scenarios().len() - ); - } - - #[test] - fn selects_named_scenario() { - let selected = select_scenarios(&["smoke".to_string()]).expect("select smoke"); - assert_eq!(selected[0].name, "smoke"); - } - - #[test] - fn unknown_scenario_has_actionable_diagnostic() { - let error = select_scenarios(&["missing".to_string()]).expect_err("unknown scenario"); - assert!(error.contains("openshell-conformance list")); - } - - #[test] - fn selects_named_policy_scenarios() { - let selected = select_scenarios(&[ - "mechanistic-proposal".to_string(), - "policy-local".to_string(), - ]) - .unwrap(); - assert_eq!( - selected - .iter() - .map(|scenario| scenario.name) - .collect::>(), - ["mechanistic-proposal", "policy-local"] - ); - } - - #[test] - fn parses_binary_override_and_json_output() { - let cli = Cli::try_parse_from([ - "openshell-conformance", - "run", - "smoke", - "--openshell-bin", - "/opt/openshell", - "--output", - "json", - ]) - .expect("parse CLI"); - let Command::Run { - openshell_bin, - output, - .. - } = cli.command - else { - panic!("expected run") - }; - assert_eq!(openshell_bin, Some(PathBuf::from("/opt/openshell"))); - assert_eq!(output, OutputFormat::Json); - } -} diff --git a/crates/openshell-conformance/src/lib.rs b/crates/openshell-conformance/src/lib.rs index 7c68a48753..4d1fcc1308 100644 --- a/crates/openshell-conformance/src/lib.rs +++ b/crates/openshell-conformance/src/lib.rs @@ -25,16 +25,15 @@ use self::executor::{CliExecutionError, CliExecutor, ProcessCli}; pub use scenarios::{ FILE_TRANSFER_GIT_FILTERING_SCENARIO, FILE_TRANSFER_PATH_SAFETY_SCENARIO, - FILE_TRANSFER_ROUND_TRIP_SCENARIO, FILE_TRANSFER_SCENARIO, MECHANISTIC_PROPOSAL_SCENARIO, + FILE_TRANSFER_ROUND_TRIP_SCENARIO, MECHANISTIC_PROPOSAL_SCENARIO, NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO, SANDBOX_LIFECYCLE_SCENARIO, SMOKE_SCENARIO, }; -/// An installed conformance scenario. +/// A portable conformance scenario exercised by the Cargo test suite. #[derive(Debug)] pub struct Scenario { pub name: &'static str, - pub description: &'static str, run: for<'a> fn(&'a mut OpenShellRunner) -> ScenarioFuture<'a>, } @@ -46,25 +45,6 @@ impl Scenario { } } -const SCENARIOS: &[Scenario] = &[ - SMOKE_SCENARIO, - SANDBOX_LIFECYCLE_SCENARIO, - FILE_TRANSFER_SCENARIO, - MECHANISTIC_PROPOSAL_SCENARIO, - NEW_HOSTNAME_PROPOSAL_SCENARIO, - POLICY_LOCAL_SCENARIO, -]; - -/// Returns every scenario compiled into this distribution. -pub fn scenarios() -> &'static [Scenario] { - SCENARIOS -} - -/// Finds a scenario by its stable command-line name. -pub fn scenario(name: &str) -> Option<&'static Scenario> { - scenarios().iter().find(|candidate| candidate.name == name) -} - const CLEANUP_TIMEOUT: Duration = Duration::from_mins(2); pub const STATUS_TIMEOUT: Duration = Duration::from_secs(30); const GATEWAY_STATUS_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(10); @@ -281,13 +261,6 @@ pub struct OpenShellCommand<'a> { } impl OpenShellRunner { - pub fn new(scenario: &str) -> Result { - Ok(Self::with_executor( - Arc::new(ProcessCli::new(PathBuf::from("openshell"))), - scenario, - )) - } - /// Uses an explicit `openshell` binary rather than resolving it on `PATH`. pub fn with_binary(binary: PathBuf, scenario: &str) -> Result { if !binary.is_file() { @@ -335,10 +308,6 @@ impl OpenShellRunner { &self.run_id } - pub fn scenario(&self) -> &str { - &self.scenario - } - pub fn step(&self, step: impl Into) -> CommandStep<'_> { CommandStep { runner: self, @@ -871,7 +840,6 @@ mod tests { let (mut runner, _cli) = test_runner(Vec::new()); assert_eq!(runner.id().len(), 10); - assert_eq!(runner.scenario(), "smoke"); runner.finished = true; } diff --git a/crates/openshell-conformance/src/scenarios/file_transfer.rs b/crates/openshell-conformance/src/scenarios/file_transfer.rs index 811bf980b2..0af908008c 100644 --- a/crates/openshell-conformance/src/scenarios/file_transfer.rs +++ b/crates/openshell-conformance/src/scenarios/file_transfer.rs @@ -17,42 +17,24 @@ const COMMAND_TIMEOUT: Duration = Duration::from_mins(2); const TRANSFER_TIMEOUT: Duration = Duration::from_mins(5); const LARGE_FILE_SIZE: usize = 512 * 1024; -/// Certify portable upload and download behavior through the public CLI. -pub const FILE_TRANSFER_SCENARIO: Scenario = Scenario { - name: "file-transfer", - description: "Verify sandbox uploads, downloads, Git filtering, and path safety.", - run: run_file_transfer, -}; - /// Certify basic file and directory upload and download behavior. pub const FILE_TRANSFER_ROUND_TRIP_SCENARIO: Scenario = Scenario { name: "file-transfer/round-trip", - description: "Verify file and directory upload and download round trips.", run: run_round_trip, }; /// Certify Git-aware upload filtering and explicit unfiltered uploads. pub const FILE_TRANSFER_GIT_FILTERING_SCENARIO: Scenario = Scenario { name: "file-transfer/git-filtering", - description: "Verify Git-aware upload selection and explicit unfiltered uploads.", run: run_git_filtering, }; /// Certify file-transfer workspace boundary and filename safety behavior. pub const FILE_TRANSFER_PATH_SAFETY_SCENARIO: Scenario = Scenario { name: "file-transfer/path-safety", - description: "Verify workspace boundary enforcement and safe filename handling.", run: run_path_safety, }; -fn run_file_transfer(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { - Box::pin(async move { - FILE_TRANSFER_ROUND_TRIP_SCENARIO.run(runner).await?; - FILE_TRANSFER_GIT_FILTERING_SCENARIO.run(runner).await?; - FILE_TRANSFER_PATH_SAFETY_SCENARIO.run(runner).await - }) -} - fn run_round_trip(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> { Box::pin(async move { let (sandbox_name, remote_root, local) = prepare_sandbox(runner, "round-trip").await?; diff --git a/crates/openshell-conformance/src/scenarios/mod.rs b/crates/openshell-conformance/src/scenarios/mod.rs index 880034d4d8..72a90925f0 100644 --- a/crates/openshell-conformance/src/scenarios/mod.rs +++ b/crates/openshell-conformance/src/scenarios/mod.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Registered, portable conformance scenarios. +//! Portable conformance scenarios exercised by the Cargo test suite. mod file_transfer; mod policy_behavior; @@ -10,7 +10,7 @@ mod smoke; pub use file_transfer::{ FILE_TRANSFER_GIT_FILTERING_SCENARIO, FILE_TRANSFER_PATH_SAFETY_SCENARIO, - FILE_TRANSFER_ROUND_TRIP_SCENARIO, FILE_TRANSFER_SCENARIO, + FILE_TRANSFER_ROUND_TRIP_SCENARIO, }; pub use policy_behavior::{ MECHANISTIC_PROPOSAL_SCENARIO, NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO, diff --git a/crates/openshell-conformance/src/scenarios/policy_behavior.rs b/crates/openshell-conformance/src/scenarios/policy_behavior.rs index e26b89e45c..386b8e0f0b 100644 --- a/crates/openshell-conformance/src/scenarios/policy_behavior.rs +++ b/crates/openshell-conformance/src/scenarios/policy_behavior.rs @@ -27,19 +27,16 @@ struct SandboxState { pub const POLICY_LOCAL_SCENARIO: Scenario = Scenario { name: "policy-local", - description: "Read and request a rule through the sandbox-local policy HTTP API.", run: run_policy_local, }; pub const MECHANISTIC_PROPOSAL_SCENARIO: Scenario = Scenario { name: "mechanistic-proposal", - description: "Turn a denied transparent TCP open into a scoped policy draft.", run: run_mechanistic_proposal, }; pub const NEW_HOSTNAME_PROPOSAL_SCENARIO: Scenario = Scenario { name: "new-hostname-proposal", - description: "Turn a denied TCP open to a hostname absent from policy into a scoped draft.", run: run_new_hostname_proposal, }; diff --git a/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs b/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs index 0b3890c458..a0b1c0016d 100644 --- a/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs +++ b/crates/openshell-conformance/src/scenarios/sandbox_lifecycle.rs @@ -31,7 +31,6 @@ struct SandboxListPage { /// Certify sandbox stop, start, and deletion lifecycle behavior. pub const SANDBOX_LIFECYCLE_SCENARIO: Scenario = Scenario { name: "sandbox-lifecycle", - description: "Verify sandbox stop, start, and deletion lifecycle behavior.", run: run_sandbox_lifecycle, }; diff --git a/crates/openshell-conformance/src/scenarios/smoke.rs b/crates/openshell-conformance/src/scenarios/smoke.rs index e9ba070dac..0f627b7fa2 100644 --- a/crates/openshell-conformance/src/scenarios/smoke.rs +++ b/crates/openshell-conformance/src/scenarios/smoke.rs @@ -31,7 +31,6 @@ struct SandboxListPage { /// Certify status -> create -> list Ready -> exec -> delete -> list empty. pub const SMOKE_SCENARIO: Scenario = Scenario { name: "smoke", - description: "Create, inspect, execute in, and delete a base sandbox.", run: run_smoke, }; diff --git a/e2e/policy-advisor/README.md b/e2e/policy-advisor/README.md index a035a9547e..14b383cb44 100644 --- a/e2e/policy-advisor/README.md +++ b/e2e/policy-advisor/README.md @@ -55,20 +55,16 @@ contents write on the repository. The test auto-resolves the token from ## Conformance coverage -The `mechanistic-proposal` and `new-hostname-proposal` conformance scenarios -check draft generation for a denied IP address and for a hostname absent from -policy. The `policy-local` scenario uses `policy.local` to inspect policy, submit -a narrow permission request, and read the resulting proposal. Run them against a configured gateway -with `--openshell-bin` pointing to the CLI under test: - -```bash -openshell-conformance run mechanistic-proposal new-hostname-proposal policy-local --openshell-bin target/debug/openshell -``` - -Run `openshell-conformance list` to see all scenario names. A manual -`Integration Tests` workflow run can select the `policy-advisor` testsuite to -run only these three scenarios against an installed candidate. Set -`artifact-run-id` to the candidate build's workflow run ID and `test-matrix` to: +The portable CLI conformance suite may include policy-related scenarios. The +current coverage checks draft generation for denied IP addresses and hostnames +absent from policy, plus sandbox-local policy inspection and permission +requests through `policy.local`. See [TESTING.md](../../TESTING.md#rust-cli-e2e) +for the supported source and installed-artifact test paths. + +A manual `Integration Tests` workflow run can select the `policy-advisor` +testsuite to run the policy-related subset against an installed candidate. Set +`artifact-run-id` to the candidate build's workflow run ID and `test-matrix` +to: ```json [{"environment":"ubuntu-docker-rootful","installer":"binaries","testsuite":"policy-advisor"}] diff --git a/e2e/rust/e2e-docker.sh b/e2e/rust/e2e-docker.sh index b19477513d..62a9cc8b61 100755 --- a/e2e/rust/e2e-docker.sh +++ b/e2e/rust/e2e-docker.sh @@ -2,7 +2,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Run standalone CLI conformance, and optionally a focused Rust e2e test, +# Run conformance suites, and optionally a focused Rust e2e test, # against a gateway using the bundled Docker compute driver. Set # OPENSHELL_GATEWAY_ENDPOINT=http://host:port to reuse an existing plaintext # gateway instead of starting an ephemeral one. diff --git a/e2e/rust/e2e-kubernetes.sh b/e2e/rust/e2e-kubernetes.sh index 95570d4bc8..f2b90dc583 100755 --- a/e2e/rust/e2e-kubernetes.sh +++ b/e2e/rust/e2e-kubernetes.sh @@ -66,7 +66,7 @@ is_operator_workspace_mode() { run_conformance() { if is_operator_workspace_mode; then - echo "note: skipping standalone CLI conformance in Kubernetes operator workspace mode; default workspace is not operator-allowlisted (see #2971)." + echo "note: skipping conformance suites in Kubernetes operator workspace mode; default workspace is not operator-allowlisted (see #2971)." return 0 fi diff --git a/e2e/support/conformance.sh b/e2e/support/conformance.sh index dd2cbcc596..9fc898b057 100644 --- a/e2e/support/conformance.sh +++ b/e2e/support/conformance.sh @@ -2,29 +2,45 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Shared helpers for running standalone conformance suites against an already +# Shared helpers for running conformance suites against an already # configured e2e gateway. -e2e_run_openshell_conformance() { - local gateway_label=${1:-OpenShell} - +e2e_require_openshell_bin() { if [ -z "${OPENSHELL_BIN:-}" ]; then echo "ERROR: OPENSHELL_BIN must point to the openshell CLI under test" >&2 return 2 fi - if [ -z "${OPENSHELL_CONFORMANCE_BIN:-}" ]; then - echo "ERROR: OPENSHELL_CONFORMANCE_BIN must point to the openshell-conformance CLI under test" >&2 + if [ ! -x "${OPENSHELL_BIN}" ]; then + echo "ERROR: openshell CLI is not executable: ${OPENSHELL_BIN}" >&2 return 2 fi - if [ ! -x "${OPENSHELL_CONFORMANCE_BIN}" ]; then - echo "ERROR: openshell conformance binary is not executable: ${OPENSHELL_CONFORMANCE_BIN}" >&2 - return 2 - fi + # Cargo runs each test from its package directory, so resolve the candidate + # relative to the caller's working directory before passing it to the tests. + local binary_dir + binary_dir="$(cd "$(dirname "${OPENSHELL_BIN}")" && pwd -P)" || return + export OPENSHELL_BIN="${binary_dir}/$(basename "${OPENSHELL_BIN}")" +} - echo "==> Running standalone CLI conformance against the ${gateway_label} gateway" - "${OPENSHELL_CONFORMANCE_BIN}" run \ - --openshell-bin "${OPENSHELL_BIN}" \ - --output json +e2e_run_openshell_conformance() { + local gateway_label=${1:-OpenShell} + local root + root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + + e2e_require_openshell_bin || return + + echo "==> Running conformance suites against the ${gateway_label} gateway" + cargo test \ + --locked \ + --manifest-path "${root}/tests/suites/conformance/Cargo.toml" \ + --package openshell-test-conformance-cli \ + --no-fail-fast \ + -- \ + --test-threads=1 \ + --nocapture } + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + e2e_run_openshell_conformance +fi diff --git a/nix/test-guest/README.md b/nix/test-guest/README.md index b5340b37f2..7f161c82fd 100644 --- a/nix/test-guest/README.md +++ b/nix/test-guest/README.md @@ -175,19 +175,18 @@ prepared guest image to a particular build or driver configuration. Compose it with `gateway-podman` after either Podman configuration. The role uses the recorded mode to select the corresponding service account. It generates configuration only for development artifacts; RPM installations -retain their packaged service and first-start configuration. For example, run -conformance after the rootless provisioners complete: +retain their packaged service and first-start configuration. For example, +check the development gateway after the rootless provisioners complete: ```shell nix run .#test-guest -- \ --distro fedora --with podman-rootless --with selinux \ --copy ./openshell:/usr/local/bin/openshell \ - --copy ./openshell-conformance:/usr/local/bin/openshell-conformance \ --copy ./openshell-gateway:/usr/local/bin/openshell-gateway \ --copy ./openshell-sandbox.tar:/usr/local/lib/openshell-sandbox.tar \ --provision openshell-development \ --provision gateway-podman \ - -- /usr/local/bin/openshell-conformance run smoke + -- /usr/local/bin/openshell status ``` `openshell-rpm` expects OpenShell to have been installed with `--install`. It diff --git a/tasks/test.toml b/tasks/test.toml index 116ee0801f..74ee585205 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -129,26 +129,12 @@ hide = true ["e2e:rust"] 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" +run = "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" -hide = true - -["e2e:cli-conformance"] -description = "Build and run the standalone CLI conformance suite against the configured gateway" -depends = ["e2e:conformance:build"] -run = [ - "if [ -z \"${OPENSHELL_BIN:-}\" ]; then cargo build -p openshell-cli; fi", - "\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" run --openshell-bin \"${OPENSHELL_BIN:-$PWD/target/debug/openshell}\"", -] - ["e2e:websocket-conformance"] description = "Run focused WebSocket conformance e2e tests against a Docker-backed gateway" run = [ @@ -175,13 +161,11 @@ run = [ ["e2e:podman"] description = "Run Rust CLI e2e tests against a Podman-backed gateway" -depends = ["e2e:conformance:build"] -run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-podman.sh" +run = "e2e/rust/e2e-podman.sh" ["e2e:podman:ci"] description = "Run the Podman Rust e2e targets enabled in required branch CI" -depends = ["e2e:conformance:build"] -run = "OPENSHELL_E2E_PODMAN_TEST_SET=ci OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-podman.sh" +run = "OPENSHELL_E2E_PODMAN_TEST_SET=ci e2e/rust/e2e-podman.sh" ["e2e:oidc-pkce"] description = "Run Linux browser PKCE and RBAC e2e tests against Keycloak and a Podman gateway" @@ -206,67 +190,56 @@ run = [ ["e2e:podman:gpu"] description = "Run GPU e2e against a standalone gateway with the Podman compute driver" env = { OPENSHELL_E2E_PODMAN_GPU = "1", OPENSHELL_E2E_PODMAN_TEST = "gpu", OPENSHELL_E2E_PODMAN_FEATURES = "e2e-podman-gpu" } -depends = ["e2e:conformance:build"] -run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-podman.sh" +run = "e2e/rust/e2e-podman.sh" ["e2e:kubernetes"] description = "Run Rust CLI e2e tests against an OpenShell gateway deployed on Kubernetes via Helm (set OPENSHELL_E2E_KUBE_CONTEXT to reuse a cluster; otherwise creates a local k3d cluster when k3d is installed; set OPENSHELL_E2E_KUBE_TEST= to scope to one test)" -depends = ["e2e:conformance:build"] -run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh" +run = "e2e/rust/e2e-kubernetes.sh" ["e2e:kubernetes:v1alpha1"] description = "Run Kubernetes e2e against Agent Sandbox v1alpha1" env = { AGENT_SANDBOX_VERSION = "v0.4.6" } -depends = ["e2e:conformance:build"] -run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh" +run = "e2e/rust/e2e-kubernetes.sh" ["e2e:kubernetes:agent-sandbox-versions"] description = "Run Kubernetes e2e against Agent Sandbox v1beta1 and v1alpha1" -depends = ["e2e:conformance:build"] run = [ - "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh", - "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" AGENT_SANDBOX_VERSION=v0.4.6 e2e/rust/e2e-kubernetes.sh", + "e2e/rust/e2e-kubernetes.sh", + "AGENT_SANDBOX_VERSION=v0.4.6 e2e/rust/e2e-kubernetes.sh", ] ["e2e:kubernetes:isolation"] description = "Run Kubernetes e2e with the workload network fence and separate supervisor" -depends = ["e2e:conformance:build"] -run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh" +run = "e2e/rust/e2e-kubernetes.sh" ["e2e:kubernetes:db"] description = "Run Kubernetes e2e with all database backend scenarios (SQLite and external PostgreSQL with existingSecret)" env = { OPENSHELL_E2E_KUBE_DB_SCENARIOS = "1" } -depends = ["e2e:conformance:build"] -run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh" +run = "e2e/rust/e2e-kubernetes.sh" ["e2e:kubernetes:ha-rebalancing"] description = "Run the Kubernetes HA rebalancing suite through Envoy against two gateway replicas and external PostgreSQL" env = { OPENSHELL_E2E_KUBE_EXTERNAL_POSTGRES_SECRET = "openshell-ha-pg", OPENSHELL_E2E_KUBE_EXTRA_VALUES = "deploy/helm/openshell/ci/values-high-availability.yaml", OPENSHELL_E2E_KUBE_TEST = "kubernetes_ha_rebalancing", OPENSHELL_E2E_KUBERNETES_FEATURES = "e2e,e2e-host-gateway,e2e-kubernetes,e2e-kubernetes-ha", OPENSHELL_E2E_KUBE_USE_ENVOY = "1" } -depends = ["e2e:conformance:build"] -run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh" +run = "e2e/rust/e2e-kubernetes.sh" ["e2e:kubernetes:credential-drivers"] description = "Run Kubernetes e2e for provider credential storage backed by Kubernetes Secrets and Vault" env = { OPENSHELL_E2E_CREDENTIAL_DRIVERS = "1", OPENSHELL_E2E_KUBE_TEST = "credential_drivers", OPENSHELL_E2E_KUBERNETES_FEATURES = "e2e,e2e-kubernetes,e2e-kubernetes-credential-drivers" } -depends = ["e2e:conformance:build"] -run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh" +run = "e2e/rust/e2e-kubernetes.sh" ["e2e:kubernetes:workspace-managed"] description = "Run Kubernetes e2e with managed workspace mode (auto-created per-workspace namespaces)" env = { OPENSHELL_E2E_CREDENTIAL_DRIVERS = "1", OPENSHELL_E2E_CREDENTIAL_DRIVER = "kubernetes-secrets", OPENSHELL_E2E_KUBE_EXTRA_VALUES = "deploy/helm/openshell/ci/values-workspace-managed.yaml", OPENSHELL_E2E_KUBE_IMAGE_PULL_SECRET = "e2e-regcred", OPENSHELL_E2E_KUBE_TEST = "workspace_namespace_managed", OPENSHELL_E2E_KUBERNETES_FEATURES = "e2e,e2e-kubernetes,e2e-kubernetes-workspace-managed" } -depends = ["e2e:conformance:build"] -run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh" +run = "e2e/rust/e2e-kubernetes.sh" ["e2e:kubernetes:workspace-operator"] description = "Run Kubernetes e2e with operator workspace mode (pre-provisioned per-workspace namespaces)" env = { OPENSHELL_E2E_KUBE_EXTRA_VALUES = "deploy/helm/openshell/ci/values-workspace-operator.yaml", OPENSHELL_E2E_KUBE_TEST = "workspace_namespace_operator", OPENSHELL_E2E_KUBERNETES_FEATURES = "e2e,e2e-kubernetes,e2e-kubernetes-workspace-operator" } -depends = ["e2e:conformance:build"] -run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh" +run = "e2e/rust/e2e-kubernetes.sh" ["e2e:vm"] description = "Start openshell-gateway with the VM compute driver and run VM e2e tests" -depends = ["e2e:conformance:build"] -run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-vm.sh" +run = "e2e/rust/e2e-vm.sh" ["e2e:gateway:no-compute-drivers"] description = "Build and launch-check openshell-gateway without compiled compute drivers" @@ -275,31 +248,26 @@ run = "bash e2e/no-compute-driver-gateway.sh" ["e2e:docker:external-driver"] description = "Run Docker conformance with a driver-free gateway and external Docker driver binary" env = { OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER = "1", OPENSHELL_E2E_DOCKER_FEATURES = "" } -depends = ["e2e:conformance:build"] -run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-docker.sh" +run = "e2e/rust/e2e-docker.sh" ["e2e:podman:external-driver"] description = "Run Podman conformance with a driver-free gateway and external Podman driver binary" env = { OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER = "1", OPENSHELL_E2E_PODMAN_FEATURES = "" } -depends = ["e2e:conformance:build"] -run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-podman.sh" +run = "e2e/rust/e2e-podman.sh" ["e2e:vm:external-driver"] description = "Run VM E2E with a driver-free gateway and external VM driver binary" env = { OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER = "1" } -depends = ["e2e:conformance:build"] -run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-vm.sh" +run = "e2e/rust/e2e-vm.sh" ["e2e:kubernetes:external-driver"] description = "Run Kubernetes conformance with a driver-free gateway and external Kubernetes driver" env = { OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER = "1", OPENSHELL_E2E_KUBE_BUILD_IMAGES = "1", OPENSHELL_E2E_KUBERNETES_FEATURES = "" } -depends = ["e2e:conformance:build"] -run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-kubernetes.sh" +run = "e2e/rust/e2e-kubernetes.sh" ["e2e:docker"] description = "Run Docker conformance and Rust e2e tests against a standalone gateway" -depends = ["e2e:conformance:build"] -run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-docker.sh" +run = "e2e/rust/e2e-docker.sh" ["e2e:mechanistic-existing-endpoint"] description = "Run #2821 existing inspected-endpoint auto-approval regression" @@ -311,8 +279,7 @@ run = [ ["e2e:docker:gpu"] description = "Run GPU e2e against a standalone gateway with the Docker compute driver" env = { OPENSHELL_E2E_DOCKER_GPU = "1", OPENSHELL_E2E_DOCKER_TEST = "gpu", OPENSHELL_E2E_DOCKER_FEATURES = "e2e-docker-gpu" } -depends = ["e2e:conformance:build"] -run = "OPENSHELL_CONFORMANCE_BIN=\"${OPENSHELL_CONFORMANCE_BIN:-$PWD/target/debug/openshell-conformance}\" e2e/rust/e2e-docker.sh" +run = "e2e/rust/e2e-docker.sh" ["test:gateway-config"] description = "Test generated local gateway TOML without starting a runtime" diff --git a/tests/artifacts.nix b/tests/artifacts.nix index c86d8f6d30..a077a1ca2a 100644 --- a/tests/artifacts.nix +++ b/tests/artifacts.nix @@ -79,7 +79,7 @@ let ''; }; - conformanceCliArchive = mkTestArchive { + conformanceTestArchive = mkTestArchive { name = "openshell-conformance"; workspacePath = "tests/suites/conformance"; manifestPath = "tests/suites/conformance/Cargo.toml"; @@ -196,7 +196,7 @@ let in rec { inherit - conformanceCliArchive + conformanceTestArchive providerRefreshKeycloakArchive podmanDriverArchive podmanE2eArchive @@ -244,7 +244,7 @@ rec { testArchives = pkgs.writeShellApplication { name = "build-artifacts-test-archives"; runtimeInputs = [ - conformanceCliArchive + conformanceTestArchive providerRefreshKeycloakArchive podmanDriverArchive podmanE2eArchive From e585b49173c70e28273c11bacb9f4b8f4df3b8e3 Mon Sep 17 00:00:00 2001 From: Shiju Date: Mon, 12 Oct 2026 16:39:31 +0000 Subject: [PATCH 08/32] feat(providers): support stable credential placeholders (#3339) * feat(providers): support stable credential placeholders Add credentials[].stable_placeholder so a running client keeps using an externally rotated static credential through the reference it already holds. Squashed replay of the earlier four commits onto upstream main 71c3cd957; re-derives the frozen schema fingerprints and adapts the provider environment revision test to the added capability argument. Signed-off-by: Shiju * fix(e2e): apply stable-placeholder supervisor image override Use ManagedGateway's per-process supervisor image override so the fixture CA reaches the supervisor when runtime image environment variables take precedence over gateway TOML. Restore the wrapper's original image after each test, following the provider-readiness fixture. Signed-off-by: Shiju * test(server): update public RPC schema fingerprint after merging main Signed-off-by: Shiju --------- Signed-off-by: Shiju --- crates/openshell-cli/src/commands/provider.rs | 51 +- .../tests/provider_commands_integration.rs | 72 + crates/openshell-core/src/grpc_client.rs | 173 ++- .../src/provider_credentials.rs | 468 +++++- crates/openshell-core/src/secrets.rs | 4 +- crates/openshell-providers/src/discovery.rs | 2 + crates/openshell-providers/src/profiles.rs | 170 +++ crates/openshell-server/src/grpc/policy.rs | 151 +- .../grpc/policy/token_grant_owners_tests.rs | 10 +- crates/openshell-server/src/grpc/provider.rs | 189 ++- .../src/grpc/provider_readiness.rs | 5 +- crates/openshell-server/src/storage_proto.rs | 27 +- crates/openshell-supervisor/src/lib.rs | 248 ++- docs/how-it-works/providers/profiles.mdx | 19 + .../tests/stable_external_placeholders.rs | 1354 +++++++++++++++++ proto/openshell.proto | 32 +- .../v1/internal/converter/coverage_test.go | 21 +- .../v1/internal/converter/profile.go | 44 +- .../v1/internal/converter/profile_test.go | 30 +- sdk/go/openshell/v1/profile_client.go | 48 +- sdk/go/openshell/v1/profile_client_test.go | 50 +- sdk/go/openshell/v1/types/profile.go | 26 +- sdk/go/proto/openshellv1/openshell.pb.go | 112 +- skills/openshell-cli/SKILL.md | 2 + 24 files changed, 3129 insertions(+), 179 deletions(-) create mode 100644 e2e/rust/tests/stable_external_placeholders.rs diff --git a/crates/openshell-cli/src/commands/provider.rs b/crates/openshell-cli/src/commands/provider.rs index bb3f9ad386..8d7804fa07 100644 --- a/crates/openshell-cli/src/commands/provider.rs +++ b/crates/openshell-cli/src/commands/provider.rs @@ -1717,6 +1717,9 @@ pub async fn provider_profile_import( let mut client = grpc_client(server, tls).await?; if !items.is_empty() { + if profiles_require_stable_placeholders(&items) { + lint_profile_items(&mut client, items.clone(), workspace).await?; + } let response = client .import_provider_profiles(ImportProviderProfilesRequest { request_id: String::new(), @@ -1763,6 +1766,9 @@ pub async fn provider_profile_update( let mut client = grpc_client(server, tls).await?; if let Some(item) = items.pop() { + if profiles_require_stable_placeholders(std::slice::from_ref(&item)) { + lint_profile_items(&mut client, vec![item.clone()], workspace).await?; + } let expected_resource_version = item .profile .as_ref() @@ -1804,14 +1810,7 @@ pub async fn provider_profile_lint( if !items.is_empty() { let mut client = grpc_client(server, tls).await?; - let response = client - .lint_provider_profiles(LintProviderProfilesRequest { - profiles: items, - workspace_scope: provider_profile_workspace_scope(workspace), - }) - .await - .into_diagnostic()? - .into_inner(); + let response = lint_profile_items(&mut client, items, workspace).await?; diagnostics.extend(response.diagnostics); } @@ -2120,6 +2119,42 @@ fn provider_refresh_strategy_name(strategy: ProviderCredentialRefreshStrategy) - } } +fn profiles_require_stable_placeholders(items: &[ProviderProfileImportItem]) -> bool { + items + .iter() + .filter_map(|item| item.profile.as_ref()) + .any(|profile| { + profile + .credentials + .iter() + .any(|credential| credential.stable_placeholder) + }) +} + +// Check support before persisting an opt-in: older gateways discard unknown +// credential fields and can otherwise report a successful revision-scoped import. +async fn lint_profile_items( + client: &mut crate::tls::GrpcClient, + items: Vec, + workspace: &str, +) -> Result { + let requires_stable = profiles_require_stable_placeholders(&items); + let response = client + .lint_provider_profiles(LintProviderProfilesRequest { + profiles: items, + workspace_scope: provider_profile_workspace_scope(workspace), + }) + .await + .into_diagnostic()? + .into_inner(); + if requires_stable && !response.supports_stable_placeholder { + return Err(miette!( + "gateway does not support stable_placeholder; upgrade the gateway before enabling it" + )); + } + Ok(response) +} + fn load_profile_import_items( file: Option<&Path>, from: Option<&Path>, diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 786ff0a156..66007f15a5 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -79,6 +79,7 @@ fn selected_workspace(scope: &Option) -> Option<&str> { struct ProviderState { providers: Arc>>, profiles: Arc>>, + supports_stable_placeholder: Arc, scoped_profiles: Arc>>, refresh_statuses: Arc>>, refresh_requests: Arc>>, @@ -1031,6 +1032,10 @@ impl OpenShell for TestOpenShell { openshell_core::proto::LintProviderProfilesResponse { diagnostics: Vec::new(), valid: true, + supports_stable_placeholder: self + .state + .supports_stable_placeholder + .load(Ordering::SeqCst), }, )) } @@ -4355,6 +4360,73 @@ async fn sandbox_provider_attach_cli_surfaces_server_errors() { ); } +#[tokio::test] +async fn provider_profile_stable_placeholder_checks_support_before_writing() { + let ts = run_server().await; + let dir = tempfile::tempdir().expect("profile directory"); + let path = dir.path().join("external.yaml"); + let stable = r" +id: external +display_name: External +category: other +resource_version: 1 +credentials: + - name: token + env_vars: [EXTERNAL_TOKEN] + stable_placeholder: true +endpoints: + - host: api.example.com + port: 443 +"; + std::fs::write(&path, stable).expect("stable profile"); + let error = + run::provider_profile_import(&ts.endpoint, Some(&path), None, None, "default", &ts.tls) + .await + .expect_err("legacy gateway cannot import the opt-in"); + assert!( + error + .to_string() + .contains("does not support stable_placeholder") + ); + assert!(ts.state.profiles.lock().await.is_empty()); + assert!( + run::provider_profile_lint(&ts.endpoint, Some(&path), None, None, "default", &ts.tls) + .await + .is_err() + ); + + // A legacy gateway still accepts ordinary profiles through the old path. + std::fs::write( + &path, + stable.replace("stable_placeholder: true", "stable_placeholder: false"), + ) + .expect("ordinary profile"); + run::provider_profile_import(&ts.endpoint, Some(&path), None, None, "default", &ts.tls) + .await + .expect("ordinary import"); + let original = ts.state.profiles.lock().await["external"].clone(); + std::fs::write(&path, stable).expect("enable opt-in"); + assert!( + run::provider_profile_update(&ts.endpoint, "external", &path, "default", &ts.tls) + .await + .is_err() + ); + assert_eq!(ts.state.profiles.lock().await["external"], original); + + ts.state + .supports_stable_placeholder + .store(true, Ordering::SeqCst); + run::provider_profile_update(&ts.endpoint, "external", &path, "default", &ts.tls) + .await + .expect("supporting gateway update"); + assert!(ts.state.profiles.lock().await["external"].credentials[0].stable_placeholder); + std::fs::write(&path, stable.replace("id: external", "id: second")).expect("second profile"); + run::provider_profile_import(&ts.endpoint, Some(&path), None, None, "default", &ts.tls) + .await + .expect("supporting gateway import"); + assert!(ts.state.profiles.lock().await["second"].credentials[0].stable_placeholder); +} + #[tokio::test] async fn provider_profile_cli_run_functions_support_custom_profiles() { let ts = run_server().await; diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 84a2be3424..ccaa774f16 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -1112,14 +1112,27 @@ pub async fn report_sandbox_configuration( /// Returns the credential snapshot and its exact readiness identity. An empty /// environment represents a sandbox without provider credentials. Transport /// failure returns an error so callers can revoke credentials and retry. +pub async fn fetch_provider_environment( + endpoint: &str, + sandbox_id: &str, +) -> Result { + fetch_provider_environment_with_stable_placeholders(endpoint, sandbox_id, false).await +} + +/// Fetch a snapshot without downgrading previously activated external stable credentials. +/// +/// Ordinary credentials remain compatible with gateways predating stable delivery. +/// Callers retain the requirement across refresh failures until an acknowledged +/// snapshot removes the last external stable credential. #[tracing::instrument( name = "supervisor.gateway.fetch_provider_environment", skip_all, fields(otel.kind = "client", otel.status_code = tracing::field::Empty) )] -pub async fn fetch_provider_environment( +pub async fn fetch_provider_environment_with_stable_placeholders( endpoint: &str, sandbox_id: &str, + requires_stable_placeholders: bool, ) -> Result { let status = ClientSpanStatus::current(); debug!(endpoint = %endpoint, sandbox_id = %sandbox_id, "Fetching provider environment"); @@ -1130,18 +1143,31 @@ pub async fn fetch_provider_environment( .get_sandbox_provider_environment(GetSandboxProviderEnvironmentRequest { sandbox_id: sandbox_id.to_string(), supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: true, }) .await .map_err(grpc_status_error)?; - status.finish(provider_environment_result(response.into_inner())) + status.finish(provider_environment_result( + response.into_inner(), + requires_stable_placeholders, + )) } /// Preserve snapshot authority and reject invalid credential expiration times. /// Unknown delivery reasons withhold credentials rather than implying readiness. +/// A gateway must acknowledge stable delivery until a successful snapshot removes it. fn provider_environment_result( inner: GetSandboxProviderEnvironmentResponse, + requires_stable_placeholders: bool, ) -> Result { + // An empty stable-key list cannot distinguish an acknowledged detach from + // an older gateway silently discarding the activated delivery requirement. + if !inner.supports_stable_placeholder_environment_keys + && (requires_stable_placeholders || !inner.stable_placeholder_environment_keys.is_empty()) + { + miette::bail!("gateway does not support the required stable credential placeholders"); + } let credential_expires_at_ms = inner .credential_expiration_times .iter() @@ -1163,30 +1189,112 @@ fn provider_environment_result( dynamic_credentials: inner.dynamic_credentials, static_credential_bindings: inner.static_credential_bindings, non_secret_environment_keys: inner.non_secret_environment_keys, + stable_placeholder_environment_keys: inner.stable_placeholder_environment_keys, }) } #[cfg(test)] mod provider_environment_tests { use super::*; + use crate::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; + use prost::Message; #[test] - fn provider_environment_preserves_readiness_identity() { - let result = provider_environment_result(GetSandboxProviderEnvironmentResponse { + fn stable_placeholder_gateway_ack_preserves_legacy_compatibility() { + let response = GetSandboxProviderEnvironmentResponse { environment: HashMap::from([("TOKEN".to_string(), "synthetic".to_string())]), - provider_env_revision: 42, - provider_attachment_epoch: "attachment-epoch".to_string(), - policy_hash: "binding-policy".to_string(), - readiness_reason: crate::proto::ProviderReadinessReason::CredentialsWithheld.into(), - credential_expiration_times: HashMap::from([( + static_credential_bindings: HashMap::from([( "TOKEN".to_string(), - prost_types::Timestamp { - seconds: 1_900_000_000, - nanos: 123_000_000, + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "api.example.com".to_string(), + port: 443, + path: "/v1/**".to_string(), + }], + credential_identity: "provider:TOKEN".to_string(), + ..Default::default() }, )]), ..Default::default() - }) + }; + // Legacy wire responses omit field 12. Also test an explicit false value + // rather than relying only on a locally constructed default message. + let absent = response.encode_to_vec(); + let mut explicit_false = absent.clone(); + explicit_false.extend_from_slice(&[0x60, 0x00]); + for encoded in [absent, explicit_false] { + let decoded = GetSandboxProviderEnvironmentResponse::decode(encoded.as_slice()) + .expect("provider response wire format"); + assert!(provider_environment_result(decoded.clone(), false).is_ok()); + assert!(provider_environment_result(decoded, true).is_err()); + } + assert!( + provider_environment_result(GetSandboxProviderEnvironmentResponse::default(), false) + .is_ok() + ); + assert!( + provider_environment_result(GetSandboxProviderEnvironmentResponse::default(), true) + .is_err() + ); + + let mut declared_stable = response.clone(); + declared_stable.stable_placeholder_environment_keys = vec!["TOKEN".to_string()]; + assert!(provider_environment_result(declared_stable, false).is_err()); + + // Managed refresh handles predate this external opt-in and do not + // require the new capability merely because they use opaque handles. + let mut managed_refresh = response; + managed_refresh + .static_credential_bindings + .get_mut("TOKEN") + .expect("binding") + .workload_credential_handle = "managed-refresh-handle".to_string(); + assert!(provider_environment_result(managed_refresh, false).is_ok()); + } + + #[test] + fn stable_placeholder_gateway_ack_preserves_environment_metadata() { + for stable_keys in [Vec::new(), vec!["TOKEN".to_string()]] { + let response = GetSandboxProviderEnvironmentResponse { + environment: HashMap::from([("TOKEN".to_string(), "synthetic".to_string())]), + provider_env_revision: 42, + stable_placeholder_environment_keys: stable_keys.clone(), + supports_stable_placeholder_environment_keys: true, + ..Default::default() + }; + let result = + provider_environment_result(response, true).expect("acknowledged delivery"); + assert_eq!(result.provider_env_revision, 42); + assert_eq!(result.stable_placeholder_environment_keys, stable_keys); + assert!( + result + .environment + .get("TOKEN") + .is_some_and(|value| value == "synthetic") + ); + } + } + + #[test] + fn provider_environment_preserves_readiness_identity() { + let result = provider_environment_result( + GetSandboxProviderEnvironmentResponse { + environment: HashMap::from([("TOKEN".to_string(), "synthetic".to_string())]), + provider_env_revision: 42, + provider_attachment_epoch: "attachment-epoch".to_string(), + policy_hash: "binding-policy".to_string(), + readiness_reason: crate::proto::ProviderReadinessReason::CredentialsWithheld.into(), + credential_expiration_times: HashMap::from([( + "TOKEN".to_string(), + prost_types::Timestamp { + seconds: 1_900_000_000, + nanos: 123_000_000, + }, + )]), + ..Default::default() + }, + false, + ) .expect("valid provider environment"); assert_eq!(result.provider_env_revision, 42); assert_eq!(result.provider_attachment_epoch, "attachment-epoch"); @@ -1207,11 +1315,14 @@ mod provider_environment_tests { #[test] fn provider_readiness_unknown_delivery_reason_is_withheld() { - let result = provider_environment_result(GetSandboxProviderEnvironmentResponse { - policy_hash: "binding-policy".to_string(), - readiness_reason: i32::MAX, - ..Default::default() - }) + let result = provider_environment_result( + GetSandboxProviderEnvironmentResponse { + policy_hash: "binding-policy".to_string(), + readiness_reason: i32::MAX, + ..Default::default() + }, + false, + ) .expect("valid provider environment"); assert_eq!( result.readiness_reason, @@ -1221,16 +1332,19 @@ mod provider_environment_tests { #[test] fn provider_environment_rejects_invalid_credential_expiration() { - let result = provider_environment_result(GetSandboxProviderEnvironmentResponse { - credential_expiration_times: HashMap::from([( - "TOKEN".to_string(), - prost_types::Timestamp { - seconds: 1_900_000_000, - nanos: -1, - }, - )]), - ..Default::default() - }); + let result = provider_environment_result( + GetSandboxProviderEnvironmentResponse { + credential_expiration_times: HashMap::from([( + "TOKEN".to_string(), + prost_types::Timestamp { + seconds: 1_900_000_000, + nanos: -1, + }, + )]), + ..Default::default() + }, + false, + ); assert!(result.is_err()); } } @@ -1436,6 +1550,9 @@ pub struct ProviderEnvironmentResult { pub dynamic_credentials: HashMap, pub static_credential_bindings: HashMap, pub non_secret_environment_keys: Vec, + /// Credential keys whose stable opaque handles are explicitly authorized + /// by the gateway's endpoint bindings. + pub stable_placeholder_environment_keys: Vec, } pub struct ProviderSubjectTokenExchangeResult { diff --git a/crates/openshell-core/src/provider_credentials.rs b/crates/openshell-core/src/provider_credentials.rs index c87e8c934d..9c079c73f8 100644 --- a/crates/openshell-core/src/provider_credentials.rs +++ b/crates/openshell-core/src/provider_credentials.rs @@ -54,6 +54,9 @@ struct ProviderCredentialStateInner { body_inventory_available: bool, known_body_keys: HashSet, static_credential_identity_epochs: HashMap, + // Revocation must retain this requirement so retrying against a downgraded + // gateway cannot silently publish revision-scoped replacements. + requires_stable_placeholders: bool, } #[derive(Debug)] @@ -131,6 +134,7 @@ impl ProviderCredentialState { known_body_keys: snapshot.child_env.keys().cloned().collect(), known_static_credential_keys: HashSet::new(), static_credential_identity_epochs: HashMap::new(), + requires_stable_placeholders: false, })), } } @@ -142,11 +146,38 @@ impl ProviderCredentialState { dynamic_credentials: HashMap, static_credential_bindings: HashMap, non_secret_environment_keys: Vec, + ) -> Result { + Self::from_bound_environment_with_stable_placeholders( + revision, + env, + credential_expires_at_ms, + dynamic_credentials, + static_credential_bindings, + non_secret_environment_keys, + Vec::new(), + ) + } + + /// Build provider state with explicitly declared stable placeholders. + /// + /// Stable placeholders are accepted only for environment keys that have a + /// complete static endpoint binding and an opaque identity-bound handle. + /// The endpoint-scoped resolver still applies the binding before resolving + /// that handle. + pub fn from_bound_environment_with_stable_placeholders( + revision: u64, + env: HashMap, + credential_expires_at_ms: HashMap, + dynamic_credentials: HashMap, + static_credential_bindings: HashMap, + non_secret_environment_keys: Vec, + stable_placeholder_environment_keys: Vec, ) -> Result { let static_credential_bindings = compile_static_credential_bindings( &env, static_credential_bindings, &non_secret_environment_keys, + &stable_placeholder_environment_keys, )?; let stable_handles = static_credential_stable_handles(&static_credential_bindings); let (child_env, generation_resolver, current_resolver) = @@ -187,6 +218,7 @@ impl ProviderCredentialState { known_body_keys: snapshot.child_env.keys().cloned().collect(), known_static_credential_keys, static_credential_identity_epochs, + requires_stable_placeholders: !stable_placeholder_environment_keys.is_empty(), })), }) } @@ -221,6 +253,7 @@ impl ProviderCredentialState { known_body_keys: snapshot.child_env.keys().cloned().collect(), known_static_credential_keys: HashSet::new(), static_credential_identity_epochs: HashMap::new(), + requires_stable_placeholders: false, })), } } @@ -399,6 +432,16 @@ impl ProviderCredentialState { .revision } + /// Whether the last installed snapshot requires external stable delivery. + /// Failed refreshes preserve this requirement until a successful bound install. + #[must_use] + pub fn requires_stable_placeholders(&self) -> bool { + self.inner + .read() + .expect("provider credential state poisoned") + .requires_stable_placeholders + } + /// Remove a key from the credential snapshot's child env. /// /// Used when a sandbox-side service (e.g., metadata server) fails to start @@ -691,11 +734,39 @@ impl ProviderCredentialState { dynamic_credentials: HashMap, static_credential_bindings: HashMap, non_secret_environment_keys: Vec, + ) -> Result { + self.install_bound_environment_with_stable_placeholders( + revision, + env, + credential_expires_at_ms, + dynamic_credentials, + static_credential_bindings, + non_secret_environment_keys, + Vec::new(), + ) + } + + /// Install one provider snapshot and its opt-in stable handles. + /// + /// Metadata validation happens before the live resolver is replaced. Any + /// invalid metadata revokes static material and leaves dynamic grants + /// available through the existing fail-closed refresh path. + #[allow(clippy::too_many_arguments)] + pub fn install_bound_environment_with_stable_placeholders( + &self, + revision: u64, + env: HashMap, + credential_expires_at_ms: HashMap, + dynamic_credentials: HashMap, + static_credential_bindings: HashMap, + non_secret_environment_keys: Vec, + stable_placeholder_environment_keys: Vec, ) -> Result { let static_credential_bindings = match compile_static_credential_bindings( &env, static_credential_bindings, &non_secret_environment_keys, + &stable_placeholder_environment_keys, ) { Ok(bindings) => bindings, Err(error) => { @@ -753,6 +824,7 @@ impl ProviderCredentialState { ); inner.non_secret_environment_keys = non_secret_environment_keys.into_iter().collect(); inner.static_credential_bindings = static_credential_bindings; + inner.requires_stable_placeholders = !stable_placeholder_environment_keys.is_empty(); Ok(inner.current.child_env.len()) } @@ -765,7 +837,15 @@ impl ProviderCredentialState { pub fn install_prepared(&self, prepared: &Self) -> usize { // Release the candidate lock before taking the live lock, including // when a caller passes another handle to the same state. - let (snapshot, generations, current_resolver, bindings, non_secret_keys, files) = { + let ( + snapshot, + generations, + current_resolver, + bindings, + non_secret_keys, + files, + requires_stable_placeholders, + ) = { let candidate = prepared .inner .read() @@ -777,6 +857,7 @@ impl ProviderCredentialState { candidate.static_credential_bindings.clone(), candidate.non_secret_environment_keys.clone(), candidate.files.clone(), + candidate.requires_stable_placeholders, ) }; let mut inner = self @@ -810,6 +891,10 @@ impl ProviderCredentialState { inner.static_credential_bindings = bindings; inner.non_secret_environment_keys = non_secret_keys; inner.files = files; + // Prepared installation is the refresh commit point. Carry its + // acknowledged delivery requirement into later fetches, including + // failures that revoke credentials without removing the requirement. + inner.requires_stable_placeholders = requires_stable_placeholders; inner.body_inventory_available = true; inner .known_body_keys @@ -861,6 +946,7 @@ fn compile_static_credential_bindings( env: &HashMap, bindings: HashMap, non_secret_environment_keys: &[String], + stable_placeholder_environment_keys: &[String], ) -> Result, StaticCredentialBindingError> { let non_secret_keys = non_secret_environment_keys .iter() @@ -871,6 +957,40 @@ fn compile_static_credential_bindings( "provider environment repeats a non-secret environment key", )); } + let stable_placeholder_keys = stable_placeholder_environment_keys + .iter() + .cloned() + .collect::>(); + if stable_placeholder_keys.len() != stable_placeholder_environment_keys.len() { + return Err(binding_error( + "provider environment repeats a stable placeholder environment key", + )); + } + if stable_placeholder_keys + .iter() + .any(|key| non_secret_keys.contains(key)) + { + return Err(binding_error( + "provider environment classifies a stable placeholder key as non-secret configuration", + )); + } + if stable_placeholder_keys + .iter() + .any(|key| !bindings.contains_key(key)) + { + return Err(binding_error( + "stable placeholder environment key has no static credential binding", + )); + } + if stable_placeholder_keys.iter().any(|key| { + bindings + .get(key) + .is_some_and(|binding| binding.workload_credential_handle.is_empty()) + }) { + return Err(binding_error( + "stable placeholder environment key has no identity-bound handle", + )); + } if bindings.keys().any(|key| non_secret_keys.contains(key)) { return Err(binding_error( "provider environment classifies a key as both credential and non-secret configuration", @@ -886,6 +1006,9 @@ fn compile_static_credential_bindings( } if bindings.keys().any(|key| !env.contains_key(key)) || non_secret_keys.iter().any(|key| !env.contains_key(key)) + || stable_placeholder_keys + .iter() + .any(|key| !env.contains_key(key)) { return Err(binding_error( "provider environment metadata references a missing environment key", @@ -1237,7 +1360,7 @@ mod tests { expected: &str, ) { let error = - compile_static_credential_bindings(&env, bindings, &non_secret_environment_keys) + compile_static_credential_bindings(&env, bindings, &non_secret_environment_keys, &[]) .expect_err("malformed provider metadata must fail validation"); assert_eq!(error.to_string(), expected); } @@ -1354,6 +1477,347 @@ mod tests { } } + #[test] + fn stable_placeholder_uses_identity_bound_handle_and_tracks_value_updates() { + let handle = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let stable_assertion_binding = stable_binding("tools.example.com", 443, "/mcp/**", handle); + let state = ProviderCredentialState::from_bound_environment_with_stable_placeholders( + 1, + HashMap::from([ + ("EXTERNAL_ASSERTION".to_string(), "old".to_string()), + ("ORDINARY_TOKEN".to_string(), "ordinary".to_string()), + ]), + HashMap::new(), + HashMap::new(), + HashMap::from([ + ( + "EXTERNAL_ASSERTION".to_string(), + stable_assertion_binding.clone(), + ), + ( + "ORDINARY_TOKEN".to_string(), + binding("tools.example.com", 443, "/mcp/**"), + ), + ]), + Vec::new(), + vec!["EXTERNAL_ASSERTION".to_string()], + ) + .expect("stable placeholder binding"); + + let stable_placeholder = state.snapshot().child_env["EXTERNAL_ASSERTION"].clone(); + assert!(stable_placeholder.contains(handle)); + let resolver = state + .resolver_for_endpoint("tools.example.com", 443, "/mcp/local") + .expect("endpoint resolver"); + assert_eq!( + resolver.resolve_placeholder(&stable_placeholder), + Some("old") + ); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:ORDINARY_TOKEN"), + None, + "ordinary endpoint-bound credentials remain revision scoped" + ); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:v1_ORDINARY_TOKEN"), + Some("ordinary") + ); + + state + .install_bound_environment_with_stable_placeholders( + 2, + HashMap::from([ + ("EXTERNAL_ASSERTION".to_string(), "new".to_string()), + ("ORDINARY_TOKEN".to_string(), "ordinary-new".to_string()), + ]), + HashMap::new(), + HashMap::new(), + HashMap::from([ + ("EXTERNAL_ASSERTION".to_string(), stable_assertion_binding), + ( + "ORDINARY_TOKEN".to_string(), + binding("tools.example.com", 443, "/mcp/**"), + ), + ]), + Vec::new(), + vec!["EXTERNAL_ASSERTION".to_string()], + ) + .expect("stable placeholder refresh"); + let refreshed = state + .resolver_for_endpoint("tools.example.com", 443, "/mcp/local") + .expect("refreshed endpoint resolver"); + assert_eq!( + refreshed.resolve_placeholder(&stable_placeholder), + Some("new") + ); + assert_eq!( + refreshed.resolve_placeholder("openshell:resolve:env:EXTERNAL_ASSERTION"), + None, + "an identityless current-name alias must remain unavailable" + ); + assert_eq!( + refreshed.resolve_placeholder("openshell:resolve:env:ORDINARY_TOKEN"), + None + ); + + let replacement_handle = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + state + .install_bound_environment_with_stable_placeholders( + 3, + HashMap::from([("EXTERNAL_ASSERTION".to_string(), "replacement".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "EXTERNAL_ASSERTION".to_string(), + stable_binding("tools.example.com", 443, "/mcp/**", replacement_handle), + )]), + Vec::new(), + vec!["EXTERNAL_ASSERTION".to_string()], + ) + .expect("replacement provider binding"); + let replacement = state + .resolver_for_endpoint("tools.example.com", 443, "/mcp/local") + .expect("replacement endpoint resolver"); + assert_eq!( + replacement.resolve_placeholder(&stable_placeholder), + None, + "a placeholder issued for an earlier provider identity must be revoked" + ); + assert_eq!( + replacement.resolve_placeholder(&state.snapshot().child_env["EXTERNAL_ASSERTION"]), + Some("replacement") + ); + } + + #[test] + fn external_stable_placeholder_rotations_reconstruction_expiry_and_detach() { + let now_ms = i64::try_from( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time") + .as_millis(), + ) + .expect("current time fits i64"); + let handle = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let bindings = HashMap::from([( + "API_KEY".to_string(), + stable_binding("api.example.com", 443, "/v1/**", handle), + )]); + let stable_keys = vec!["API_KEY".to_string()]; + let environment = + |revision| HashMap::from([("API_KEY".to_string(), format!("synthetic-{revision}"))]); + let expires = |expiry| HashMap::from([("API_KEY".to_string(), expiry)]); + let state = ProviderCredentialState::from_bound_environment_with_stable_placeholders( + 1, + environment(1), + expires(now_ms + 60_000), + HashMap::new(), + bindings.clone(), + Vec::new(), + stable_keys.clone(), + ) + .expect("external stable binding"); + let original = state.snapshot().child_env["API_KEY"].clone(); + + // Exceed the retained revision queue while keeping the original workload + // reference. External metadata must reach every installation unchanged. + for revision in 2..=13 { + state + .install_bound_environment_with_stable_placeholders( + revision, + environment(revision), + expires(now_ms + 60_000), + HashMap::new(), + bindings.clone(), + Vec::new(), + stable_keys.clone(), + ) + .expect("external value update"); + assert!(state.snapshot().child_env["API_KEY"] == original); + let resolver = state + .resolver_for_endpoint("api.example.com", 443, "/v1/chat/completions") + .expect("authorized endpoint resolver"); + assert!( + resolver.resolve_placeholder(&original) + == Some(format!("synthetic-{revision}").as_str()) + ); + assert!( + resolver + .resolve_placeholder("openshell:resolve:env:API_KEY") + .is_none() + ); + assert!( + resolver + .resolve_placeholder("openshell:resolve:env:v1_API_KEY") + .is_none() + ); + } + + let reconstructed = + ProviderCredentialState::from_bound_environment_with_stable_placeholders( + 13, + environment(13), + expires(now_ms + 60_000), + HashMap::new(), + bindings.clone(), + Vec::new(), + stable_keys.clone(), + ) + .expect("reconstructed external supervisor state"); + assert!(reconstructed.snapshot().child_env["API_KEY"] == original); + assert!( + reconstructed + .resolver_for_endpoint("api.example.com", 443, "/v1/chat/completions") + .expect("reconstructed resolver") + .resolve_placeholder(&original) + == Some("synthetic-13") + ); + for (host, port, path) in [ + ("other.example.com", 443, "/v1/chat/completions"), + ("api.example.com", 8443, "/v1/chat/completions"), + ("api.example.com", 443, "/other/chat/completions"), + ] { + let resolver = reconstructed + .resolver_for_endpoint(host, port, path) + .expect("endpoint-scoped resolver"); + assert!(resolver.rewrite_header_value(&original).is_err()); + } + + // Current-value expiry must not fall back to a previously valid value. + reconstructed + .install_bound_environment_with_stable_placeholders( + 14, + environment(14), + expires(now_ms - 1), + HashMap::new(), + bindings, + Vec::new(), + stable_keys, + ) + .expect("expired external value installation"); + let resolver = reconstructed + .resolver_for_endpoint("api.example.com", 443, "/v1/chat/completions") + .expect("expired resolver"); + assert!(resolver.resolve_placeholder(&original).is_none()); + + // An activated detach removes the current binding and all authority for + // a still-running process, even when it retains its original reference. + state + .install_bound_environment_with_stable_placeholders( + 15, + HashMap::new(), + HashMap::new(), + HashMap::new(), + HashMap::new(), + Vec::new(), + Vec::new(), + ) + .expect("detached provider snapshot"); + assert!(state.snapshot().child_env.is_empty()); + assert!( + state + .resolver_for_endpoint("api.example.com", 443, "/v1/chat/completions") + .is_none_or(|resolver| resolver.resolve_placeholder(&original).is_none()) + ); + } + + #[test] + fn stable_placeholder_requirement_survives_failed_refresh() { + let state = ProviderCredentialState::from_bound_environment_with_stable_placeholders( + 1, + HashMap::from([("TOKEN".to_string(), "synthetic".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "TOKEN".to_string(), + stable_binding( + "api.example.com", + 443, + "/v1/**", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ), + )]), + Vec::new(), + vec!["TOKEN".to_string()], + ) + .expect("stable credential snapshot"); + assert!(state.requires_stable_placeholders()); + + // A transport failure and a later malformed response must both retain + // the requirement, even though neither leaves static material active. + state.revoke_static_provider_environment(2); + assert!(state.requires_stable_placeholders()); + assert!(state.snapshot().child_env.is_empty()); + assert!( + state + .install_bound_environment_with_stable_placeholders( + 3, + HashMap::from([("TOKEN".to_string(), "synthetic".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::new(), + Vec::new(), + Vec::new(), + ) + .is_err() + ); + assert!(state.requires_stable_placeholders()); + + // The response adapter requires acknowledgment before this successful + // empty install can represent removal of the external stable profile. + state + .install_bound_environment_with_stable_placeholders( + 4, + HashMap::new(), + HashMap::new(), + HashMap::new(), + HashMap::new(), + Vec::new(), + Vec::new(), + ) + .expect("activated detach"); + assert!(!state.requires_stable_placeholders()); + } + + #[test] + fn stable_placeholder_without_endpoint_binding_is_rejected() { + let error = ProviderCredentialState::from_bound_environment_with_stable_placeholders( + 1, + HashMap::from([("EXTERNAL_ASSERTION".to_string(), "secret".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::new(), + Vec::new(), + vec!["EXTERNAL_ASSERTION".to_string()], + ) + .expect_err("an unbound stable alias must fail closed"); + assert_eq!( + error.to_string(), + "stable placeholder environment key has no static credential binding" + ); + } + + #[test] + fn stable_placeholder_without_identity_bound_handle_is_rejected() { + let error = ProviderCredentialState::from_bound_environment_with_stable_placeholders( + 1, + HashMap::from([("API_KEY".to_string(), "secret".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/**"), + )]), + Vec::new(), + vec!["API_KEY".to_string()], + ) + .expect_err("a stable placeholder must carry an identity-bound handle"); + assert_eq!( + error.to_string(), + "stable placeholder environment key has no identity-bound handle" + ); + } + #[test] fn multiple_credentials_resolve_only_at_their_own_endpoints() { let mut binding_a = binding("a.example.com", 443, "/a/**"); diff --git a/crates/openshell-core/src/secrets.rs b/crates/openshell-core/src/secrets.rs index e17cd34a8b..2e51c9de28 100644 --- a/crates/openshell-core/src/secrets.rs +++ b/crates/openshell-core/src/secrets.rs @@ -244,11 +244,11 @@ impl SecretResolver { } /// Build workload environment and resolver snapshots with gateway-issued - /// stable handles for selected refresh-managed credentials. + /// stable handles for selected credentials. /// /// Stable credentials are registered only in the current resolver. Their /// previous values never enter the bounded revision-generation queue, so a - /// revoked or replaced handle cannot resolve an older access token. + /// revoked or replaced handle cannot resolve an older secret value. pub(crate) fn from_provider_env_for_current_revision_with_stable_handles( provider_env: HashMap, credential_expires_at_ms: HashMap, diff --git a/crates/openshell-providers/src/discovery.rs b/crates/openshell-providers/src/discovery.rs index 2249adaf4a..7a4fcdfd69 100644 --- a/crates/openshell-providers/src/discovery.rs +++ b/crates/openshell-providers/src/discovery.rs @@ -104,6 +104,7 @@ mod tests { name: "api_key".to_string(), env_vars: vec!["CUSTOM_API_KEY".to_string(), "CUSTOM_API_TOKEN".to_string()], required: true, + stable_placeholder: false, description: String::new(), auth_style: String::new(), header_name: String::new(), @@ -116,6 +117,7 @@ mod tests { name: "secondary".to_string(), env_vars: vec!["CUSTOM_API_KEY".to_string()], required: false, + stable_placeholder: false, description: String::new(), auth_style: String::new(), header_name: String::new(), diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 1f7655aae3..e289d4b80d 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -93,6 +93,10 @@ pub struct CredentialProfile { pub env_vars: Vec, #[serde(default)] pub required: bool, + /// Preserve an issued workload reference across external value updates. + /// Requires environment aliases and excludes gateway-owned credentials. + #[serde(default, skip_serializing_if = "is_false")] + pub stable_placeholder: bool, #[serde(default)] pub auth_style: String, #[serde(default)] @@ -813,6 +817,7 @@ impl ProviderTypeProfile { description: credential.description.clone(), env_vars: credential.env_vars.clone(), required: credential.required, + stable_placeholder: credential.stable_placeholder, auth_style: credential.auth_style.clone(), header_name: credential.header_name.clone(), query_param: credential.query_param.clone(), @@ -991,6 +996,7 @@ impl ProviderTypeProfile { description: credential.description.clone(), env_vars: credential.env_vars.clone(), required: credential.required, + stable_placeholder: credential.stable_placeholder, auth_style: credential.auth_style.clone(), header_name: credential.header_name.clone(), query_param: credential.query_param.clone(), @@ -2293,7 +2299,44 @@ pub fn validate_profile_set( )); let mut env_vars = HashSet::new(); + let co_minted_credentials = profile.co_minted_credential_names(); for credential in &profile.credentials { + // Each credential has one placeholder owner. Gateway-managed + // credentials derive their handle from a refresh authorization + // epoch, including outputs declared by a sibling credential. + if credential.stable_placeholder + && (credential + .refresh + .as_ref() + .is_some_and(CredentialRefreshProfile::is_gateway_mintable) + || co_minted_credentials.contains(credential.name.as_str())) + { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.stable_placeholder", + "stable_placeholder is incompatible with gateway-managed credential refresh", + )); + } + // Stored-name fallback credentials have no workload environment + // reference. Accepting this opt-in would silently leave them in + // revision mode because stable delivery selects environment aliases. + if credential.stable_placeholder && credential.env_vars.is_empty() { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.stable_placeholder", + "stable_placeholder requires at least one environment variable", + )); + } + if credential.stable_placeholder && credential.token_grant.is_some() { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.stable_placeholder", + "stable_placeholder is incompatible with token_grant credentials", + )); + } for env_var in &credential.env_vars { if env_var.trim().is_empty() { diagnostics.push(ProfileValidationDiagnostic::error( @@ -4318,6 +4361,133 @@ credentials: assert_eq!(profile.credential_env_vars(), vec!["EXAMPLE_API_KEY"]); } + #[test] + fn stable_placeholder_defaults_false_and_round_trips() { + let default_profile = parse_profile_yaml( + r" +id: default-placeholder +display_name: Default Placeholder +credentials: + - name: token + env_vars: [TOKEN] +", + ) + .expect("profile should parse"); + assert!(!default_profile.credentials[0].stable_placeholder); + + let stable_profile = parse_profile_yaml( + r" +id: stable-placeholder +display_name: Stable Placeholder +credentials: + - name: token + env_vars: [TOKEN] + stable_placeholder: true +", + ) + .expect("profile should parse"); + assert!(stable_profile.credentials[0].stable_placeholder); + + let proto = stable_profile.to_proto(); + assert!(proto.credentials[0].stable_placeholder); + let round_trip = ProviderTypeProfile::from_proto(&proto); + assert!(round_trip.credentials[0].stable_placeholder); + + let exported = profile_to_yaml(&round_trip).expect("yaml"); + assert!(exported.contains("stable_placeholder: true")); + } + + #[test] + fn stable_placeholder_rejects_co_minted_refresh_outputs() { + for id in ["aws", "aws-s3"] { + for output in ["secret_access_key", "session_token"] { + let mut profile = crate::example_profiles::load(id); + let credential = profile + .credentials + .iter_mut() + .find(|credential| credential.name == output) + .expect("example profile refresh output"); + assert!(credential.refresh.is_none()); + credential.stable_placeholder = true; + let diagnostics = validate_profile_set(&[(format!("{id}.yaml"), profile)]); + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic.field == "credentials.stable_placeholder" + && diagnostic.message + == "stable_placeholder is incompatible with gateway-managed credential refresh" + })); + } + } + } + + #[test] + fn stable_placeholder_rejects_credentials_without_environment_aliases() { + let profile = parse_profile_yaml( + r" +id: stored-name-placeholder +display_name: Stored Name Placeholder +credentials: + - name: TOKEN + stable_placeholder: true +", + ) + .expect("stored-name profile should parse"); + assert_eq!(profile.credentials[0].accepted_stored_keys(), vec!["TOKEN"]); + let diagnostics = validate_profile_set(&[("stored-name.yaml".to_string(), profile)]); + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic.field == "credentials.stable_placeholder" + && diagnostic.message + == "stable_placeholder requires at least one environment variable" + })); + } + + #[test] + fn stable_placeholder_rejects_runtime_minted_credentials() { + let refresh_profile = parse_profile_yaml( + r" +id: refresh-placeholder +display_name: Refresh Placeholder +credentials: + - name: token + env_vars: [TOKEN] + stable_placeholder: true + refresh: + strategy: oauth2_refresh_token + token_url: https://auth.example.com/token +", + ) + .expect("refresh profile should parse"); + let refresh_diagnostics = + validate_profile_set(&[("refresh.yaml".to_string(), refresh_profile)]); + assert!(refresh_diagnostics.iter().any(|diagnostic| { + diagnostic.field == "credentials.stable_placeholder" + && diagnostic.message + == "stable_placeholder is incompatible with gateway-managed credential refresh" + })); + + let token_grant_profile = parse_profile_yaml( + r" +id: token-grant-placeholder +display_name: Token Grant Placeholder +credentials: + - name: token + env_vars: [TOKEN] + stable_placeholder: true + auth_style: bearer + header_name: Authorization + token_grant: + token_endpoint: https://auth.example.com/token +", + ) + .expect("token grant profile should parse"); + let token_grant_diagnostics = + validate_profile_set(&[("token-grant.yaml".to_string(), token_grant_profile)]); + assert!(token_grant_diagnostics.iter().any(|diagnostic| { + diagnostic.field == "credentials.stable_placeholder" + && diagnostic.message + == "stable_placeholder is incompatible with token_grant credentials" + })); + } + #[test] fn profile_discovery_metadata_round_trips_through_proto_and_yaml() { let profile = parse_profile_yaml( diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 8edf83139e..6de4dc3a7d 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -3540,6 +3540,9 @@ pub(super) async fn handle_get_sandbox_provider_environment( ) -> Result, Status> { let sandbox_id = request.get_ref().sandbox_id.clone(); let supports_static_credential_bindings = request.get_ref().supports_static_credential_bindings; + let supports_stable_placeholder_environment_keys = request + .get_ref() + .supports_stable_placeholder_environment_keys; crate::auth::guard::enforce_sandbox_scope(&request, &sandbox_id)?; drop(request); @@ -3549,9 +3552,13 @@ pub(super) async fn handle_get_sandbox_provider_environment( .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; - let environment = - load_sandbox_provider_environment(state, &sandbox, supports_static_credential_bindings) - .await?; + let environment = load_sandbox_provider_environment( + state, + &sandbox, + supports_static_credential_bindings, + supports_stable_placeholder_environment_keys, + ) + .await?; Ok(Response::new(environment)) } @@ -3561,6 +3568,7 @@ pub(super) async fn load_sandbox_provider_environment( state: &Arc, sandbox: &Sandbox, supports_static_credential_bindings: bool, + supports_stable_placeholder_environment_keys: bool, ) -> Result { let sandbox_id = sandbox.object_id().to_string(); let workspace = sandbox.object_workspace().to_string(); @@ -3661,6 +3669,32 @@ pub(super) async fn load_sandbox_provider_environment( .remove(&key); provider_environment.static_credential_keys.remove(&key); } + if !supports_stable_placeholder_environment_keys { + let unsupported_stable_keys = provider_environment + .stable_placeholder_environment_keys + .iter() + .cloned() + .collect::>(); + if !unsupported_stable_keys.is_empty() { + readiness_reason = ProviderReadinessReason::UnsupportedSupervisor; + } + for key in unsupported_stable_keys { + warn!( + sandbox_id = %sandbox_id, + key = %key, + "withholding stable-placeholder credential from incompatible supervisor" + ); + provider_environment.environment.remove(&key); + provider_environment + .credential_expiration_times + .remove(&key); + provider_environment.static_credential_bindings.remove(&key); + provider_environment.static_credential_keys.remove(&key); + } + provider_environment + .stable_placeholder_environment_keys + .clear(); + } } else { if !provider_environment.static_credential_keys.is_empty() { readiness_reason = ProviderReadinessReason::UnsupportedSupervisor; @@ -3670,6 +3704,9 @@ pub(super) async fn load_sandbox_provider_environment( provider_environment.credential_expiration_times.remove(key); } provider_environment.static_credential_bindings.clear(); + provider_environment + .stable_placeholder_environment_keys + .clear(); } info!( @@ -3686,6 +3723,23 @@ pub(super) async fn load_sandbox_provider_environment( .filter(|key| !provider_environment.static_credential_keys.contains(*key)) .cloned() .collect(); + if provider_environment + .stable_placeholder_environment_keys + .iter() + .any(|key| { + !provider_environment.environment.contains_key(key) + || !provider_environment.static_credential_keys.contains(key) + }) + { + return Err(Status::failed_precondition( + "provider environment contains an unbound stable placeholder key", + )); + } + let mut stable_placeholder_environment_keys = provider_environment + .stable_placeholder_environment_keys + .into_iter() + .collect::>(); + stable_placeholder_environment_keys.sort(); let credential_expiration_times = provider_environment .credential_expiration_times @@ -3705,6 +3759,8 @@ pub(super) async fn load_sandbox_provider_environment( dynamic_credentials: provider_environment.dynamic_credentials, static_credential_bindings: provider_environment.static_credential_bindings, non_secret_environment_keys, + stable_placeholder_environment_keys, + supports_stable_placeholder_environment_keys: true, provider_attachment_epoch: spec.provider_attachment_epoch.clone(), policy_hash: deterministic_policy_hash(&effective_policy), readiness_reason: readiness_reason.into(), @@ -11573,6 +11629,7 @@ mod tests { with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-snapshot-consistency".to_string(), supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: true, })), ) .await @@ -13181,6 +13238,7 @@ mod tests { with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-provider-env".to_string(), supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: true, })), ) .await @@ -13193,6 +13251,7 @@ mod tests { with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-provider-env".to_string(), supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: true, })), ) .await @@ -13265,6 +13324,7 @@ mod tests { with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-files-and-credentials".to_string(), supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: false, })), ) .await @@ -13312,6 +13372,7 @@ mod tests { with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-static-ready".to_string(), supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: false, })), ) .await @@ -13370,6 +13431,7 @@ mod tests { with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-legacy-provider-env".to_string(), supports_static_credential_bindings: false, + supports_stable_placeholder_environment_keys: false, })), ) .await @@ -13414,6 +13476,7 @@ mod tests { with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-unbound-provider-env".to_string(), supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: true, })), ) .await @@ -13443,7 +13506,7 @@ mod tests { } #[tokio::test] - async fn provider_environment_uses_policy_binding_for_endpointless_profile() { + async fn provider_environment_uses_policy_binding_and_negotiates_stable_placeholders() { use openshell_core::proto::{ GetSandboxConfigRequest, GetSandboxProviderEnvironmentRequest, NetworkCredentialBinding, ProviderProfile, ProviderProfileCategory, @@ -13466,6 +13529,7 @@ mod tests { credentials: vec![openshell_core::proto::ProviderProfileCredential { name: "cloud_token".to_string(), env_vars: vec!["CLOUD_TOKEN".to_string()], + stable_placeholder: true, ..Default::default() }], endpoints: Vec::new(), @@ -13519,11 +13583,59 @@ mod tests { .await .unwrap() .into_inner(); + // Both endpoint enforcement and external stable-handle installation are + // required. Neither capability on its own permits credential delivery. + for (supports_bindings, supports_stable) in [(false, false), (false, true), (true, false)] { + let incompatible_environment = handle_get_sandbox_provider_environment( + &state, + with_user(Request::new(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sb-policy-binding".to_string(), + supports_static_credential_bindings: supports_bindings, + supports_stable_placeholder_environment_keys: supports_stable, + })), + ) + .await + .unwrap() + .into_inner(); + assert!(incompatible_environment.supports_stable_placeholder_environment_keys); + assert_eq!( + incompatible_environment.readiness_reason, + ProviderReadinessReason::UnsupportedSupervisor as i32, + ); + assert!( + !incompatible_environment + .environment + .contains_key("CLOUD_TOKEN") + ); + assert!( + !incompatible_environment + .static_credential_bindings + .contains_key("CLOUD_TOKEN") + ); + assert!( + !incompatible_environment + .credential_expiration_times + .contains_key("CLOUD_TOKEN") + ); + assert!( + !incompatible_environment + .non_secret_environment_keys + .iter() + .any(|key| key == "CLOUD_TOKEN") + ); + assert!( + incompatible_environment + .stable_placeholder_environment_keys + .is_empty() + ); + } + let environment = handle_get_sandbox_provider_environment( &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-policy-binding".to_string(), supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: true, })), ) .await @@ -13539,6 +13651,11 @@ mod tests { environment.environment.get("CLOUD_TOKEN"), Some(&"cloud-secret".to_string()) ); + assert_eq!( + environment.stable_placeholder_environment_keys, + vec!["CLOUD_TOKEN".to_string()] + ); + assert!(environment.supports_stable_placeholder_environment_keys); assert_eq!( environment.static_credential_bindings["CLOUD_TOKEN"].endpoints, vec![StaticCredentialEndpointBinding { @@ -13547,6 +13664,13 @@ mod tests { path: String::new(), }] ); + assert_eq!( + environment.static_credential_bindings["CLOUD_TOKEN"] + .workload_credential_handle + .len(), + 64, + "stable external credentials must carry an identity-bound handle" + ); assert_eq!( config.provider_env_revision, environment.provider_env_revision, "config and provider environment must advertise one atomic revision" @@ -13589,6 +13713,7 @@ mod tests { with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-policy-binding".to_string(), supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: true, })), ) .await @@ -13611,6 +13736,11 @@ mod tests { next_environment.static_credential_bindings["CLOUD_TOKEN"].endpoints[0].host, "api2.cloud.example" ); + assert!( + next_environment.static_credential_bindings["CLOUD_TOKEN"].workload_credential_handle + != environment.static_credential_bindings["CLOUD_TOKEN"].workload_credential_handle, + "changing the effective endpoint must revoke the originally issued handle" + ); let mut unbound_policy = next_policy; unbound_policy @@ -13649,6 +13779,7 @@ mod tests { with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-policy-binding".to_string(), supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: true, })), ) .await @@ -13745,6 +13876,7 @@ mod tests { with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-mixed-provider-env".to_string(), supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: true, })), ) .await @@ -13853,6 +13985,7 @@ mod tests { with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-token-exchange-subject".to_string(), supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: true, })), ) .await @@ -13920,6 +14053,7 @@ mod tests { with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-provider-revision".to_string(), supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: true, })), ) .await @@ -13959,6 +14093,7 @@ mod tests { with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-provider-revision".to_string(), supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: true, })), ) .await @@ -14005,7 +14140,7 @@ mod tests { let mut revisions = HashSet::new(); for _ in 0..16 { let config = load_sandbox_config(&state, &sandbox).await.unwrap(); - let environment = load_sandbox_provider_environment(&state, &sandbox, true) + let environment = load_sandbox_provider_environment(&state, &sandbox, true, true) .await .unwrap(); assert_eq!( @@ -14450,6 +14585,7 @@ mod tests { with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-attach-lifecycle".to_string(), supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: true, })), ) .await @@ -14483,6 +14619,7 @@ mod tests { with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-attach-lifecycle".to_string(), supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: true, })), ) .await @@ -14524,6 +14661,7 @@ mod tests { with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-attach-lifecycle".to_string(), supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: true, })), ) .await @@ -14628,6 +14766,7 @@ mod tests { with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-attach-lifecycle".to_string(), supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: true, })), ) .await @@ -14664,6 +14803,7 @@ mod tests { with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-attach-lifecycle".to_string(), supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: true, })), ) .await @@ -14704,6 +14844,7 @@ mod tests { with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-attach-lifecycle".to_string(), supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: true, })), ) .await diff --git a/crates/openshell-server/src/grpc/policy/token_grant_owners_tests.rs b/crates/openshell-server/src/grpc/policy/token_grant_owners_tests.rs index 949e1689d3..b3608bd942 100644 --- a/crates/openshell-server/src/grpc/policy/token_grant_owners_tests.rs +++ b/crates/openshell-server/src/grpc/policy/token_grant_owners_tests.rs @@ -92,7 +92,7 @@ async fn composed_owners_survive_name_collisions_and_audience_overrides() { let policy = config.policy.unwrap(); assert!(policy.network_policies.contains_key("_provider_team_a")); assert!(policy.network_policies.contains_key("_provider_team_a_2")); - let environment = load_sandbox_provider_environment(&state, &sandbox, true) + let environment = load_sandbox_provider_environment(&state, &sandbox, true, true) .await .unwrap(); assert_eq!(environment.policy_hash, deterministic_policy_hash(&policy)); @@ -153,7 +153,7 @@ async fn refreshed_profile_owners_do_not_match_the_previous_policy() { let mut replacement = profile("owner-a", "/alpha/**"); replacement.binaries[0].path = "/usr/bin/replacement-client".into(); store_profile(&state, replacement).await; - let environment = load_sandbox_provider_environment(&state, &sandbox, true) + let environment = load_sandbox_provider_environment(&state, &sandbox, true, true) .await .unwrap(); let replacement_policy = load_sandbox_config(&state, &sandbox) @@ -184,7 +184,7 @@ async fn refreshed_profile_owners_do_not_match_the_previous_policy() { async fn global_policy_replaces_grant_authorities_and_preserves_profile_destinations() { let state = test_server_state().await; let sandbox = provider_sandbox(&state).await; - let before = load_sandbox_provider_environment(&state, &sandbox, true) + let before = load_sandbox_provider_environment(&state, &sandbox, true, true) .await .unwrap(); let mut global = ProtoSandboxPolicy { @@ -228,7 +228,7 @@ async fn global_policy_replaces_grant_authorities_and_preserves_profile_destinat assert_eq!(global.network_policies.len(), 1); let global_owner = &global.network_policies["global_api"].endpoints[0].token_grant_owner; assert_ne!(global_owner, "forged-global-owner"); - let after = load_sandbox_provider_environment(&state, &sandbox, true) + let after = load_sandbox_provider_environment(&state, &sandbox, true, true) .await .unwrap(); assert_ne!(before.provider_env_revision, after.provider_env_revision); @@ -270,7 +270,7 @@ async fn global_policy_replaces_grant_authorities_and_preserves_profile_destinat .await .unwrap(); let replacement_config = load_sandbox_config(&state, &sandbox).await.unwrap(); - let replacement_environment = load_sandbox_provider_environment(&state, &sandbox, true) + let replacement_environment = load_sandbox_provider_environment(&state, &sandbox, true, true) .await .unwrap(); assert_ne!( diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 9d8b242bd8..8b2a3bda15 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -42,6 +42,7 @@ use super::validation::{validate_provider_fields, validate_provider_mutable_fiel use super::{MAX_MAP_KEY_LEN, MAX_MAP_VALUE_LEN, MAX_PROVIDER_CONFIG_ENTRIES}; const GATEWAY_SPIFFE_WORKLOAD_API_SOCKET: &str = "OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET"; +const EXTERNAL_STABLE_PLACEHOLDER_EPOCH: &str = "external-stable-placeholder-v1"; // --------------------------------------------------------------------------- // CRUD helpers @@ -75,6 +76,7 @@ pub(super) struct ProviderEnvironment { pub dynamic_credentials: HashMap, pub static_credential_bindings: HashMap, pub static_credential_keys: HashSet, + pub stable_placeholder_environment_keys: HashSet, pub files: HashMap, } @@ -1129,6 +1131,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin let mut expires = HashMap::new(); let mut static_credential_bindings = HashMap::new(); let mut static_credential_keys = HashSet::new(); + let mut stable_placeholder_environment_keys = HashSet::new(); let mut files = HashMap::new(); let mut file_env_keys = HashSet::new(); let mut readiness_reason = openshell_core::proto::ProviderReadinessReason::Unspecified; @@ -1145,6 +1148,17 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin &provider.r#type, &provider.profile_workspace, ); + let profile_stable_placeholder_keys = profile + .as_ref() + .map(|profile| { + profile + .credentials + .iter() + .filter(|credential| credential.stable_placeholder) + .flat_map(|credential| credential.env_vars.iter().cloned()) + .collect::>() + }) + .unwrap_or_default(); let accepted_stored_credential_keys = profile.as_ref().map(|profile| { profile .credentials @@ -1278,6 +1292,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin key, endpoints, refresh_epochs.get(key).map(String::as_str), + profile_stable_placeholder_keys.contains(key), ), ); } @@ -1356,6 +1371,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin &key, endpoints, refresh_epochs.get(&key).map(String::as_str), + profile_stable_placeholder_keys.contains(&key), ), ); } @@ -1373,6 +1389,18 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin // or populates its own keys. Cross-provider credential/config // collisions have already been rejected by the validation above. inject_provider_plugin_environment(catalog, provider, ®istry, &mut provider_env); + for key in profile_stable_placeholder_keys { + // A stable placeholder is emitted only when the binding carries + // the opaque handle that binds it to the sandbox, provider, + // credential key, and endpoint authorization. + if provider_env.contains_key(&key) + && static_credential_bindings + .get(&key) + .is_some_and(|binding| !binding.workload_credential_handle.is_empty()) + { + stable_placeholder_environment_keys.insert(key); + } + } if let Some(profile) = profile.as_ref() { if !profile.files.is_empty() && (name.is_empty() @@ -1433,6 +1461,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin dynamic_credentials: resolve_dynamic_credentials_from_records(catalog, records)?, static_credential_bindings, static_credential_keys, + stable_placeholder_environment_keys, files, }) } @@ -1476,9 +1505,15 @@ fn static_credential_binding( key: &str, endpoints: &[StaticCredentialEndpointBinding], authorization_epoch: Option<&str>, + stable_external_placeholder: bool, ) -> StaticCredentialBinding { + // External stable placeholders do not have a refresh authorization epoch. + // A fixed domain value keeps their handle stable across value updates while + // the remaining handle inputs revoke it on identity or endpoint changes. + let handle_epoch = authorization_epoch + .or_else(|| stable_external_placeholder.then_some(EXTERNAL_STABLE_PLACEHOLDER_EPOCH)); let workload_credential_handle = sandbox_id - .zip(authorization_epoch) + .zip(handle_epoch) .map(|(sandbox_id, epoch)| { derive_workload_credential_handle(sandbox_id, &record.object_id, key, epoch, endpoints) }) @@ -3086,6 +3121,7 @@ pub(super) async fn handle_lint_provider_profiles( Ok(Response::new(LintProviderProfilesResponse { diagnostics: diagnostics.into_iter().map(proto_diagnostic).collect(), valid, + supports_stable_placeholder: true, })) } @@ -5400,6 +5436,7 @@ mod tests { description: String::new(), env_vars: Vec::new(), required: false, + stable_placeholder: false, auth_style: "bearer".to_string(), header_name: "Authorization".to_string(), query_param: String::new(), @@ -6499,6 +6536,7 @@ mod tests { description: String::new(), env_vars: vec![env_var.to_string()], required: true, + stable_placeholder: false, auth_style: "bearer".to_string(), header_name: "authorization".to_string(), query_param: String::new(), @@ -6569,6 +6607,7 @@ mod tests { description: String::new(), env_vars: vec![env_var.to_string()], required, + stable_placeholder: false, auth_style: "bearer".to_string(), header_name: "authorization".to_string(), query_param: String::new(), @@ -6585,6 +6624,7 @@ mod tests { description: String::new(), env_vars: Vec::new(), required: true, + stable_placeholder: false, auth_style: "bearer".to_string(), header_name: "authorization".to_string(), query_param: String::new(), @@ -7402,6 +7442,7 @@ mod tests { .into_inner(); assert!(!response.valid); + assert!(response.supports_stable_placeholder); assert!(response.diagnostics.iter().any(|diagnostic| { diagnostic.profile_id == "lint-bad" && diagnostic.field == "endpoints[0]" @@ -10696,6 +10737,7 @@ mod tests { env_vars: vec!["DELEGATED_ACCESS_TOKEN".to_string()], token_grant_owners: Vec::new(), required: true, + stable_placeholder: false, auth_style: "bearer".to_string(), header_name: "authorization".to_string(), query_param: String::new(), @@ -11273,6 +11315,66 @@ mod tests { ); } + #[test] + fn external_stable_placeholder_binding_preserves_only_value_changes() { + let mut record = ProviderEnvironmentRecord { + name: "external-provider".to_string(), + object_id: "provider-instance-a".to_string(), + resource_version: 1, + provider: Provider::default(), + refresh_states: Vec::new(), + }; + let endpoints = vec![StaticCredentialEndpointBinding { + host: "api.example.com".to_string(), + port: 443, + path: "/v1/**".to_string(), + }]; + let external_binding = + |sandbox, record: &ProviderEnvironmentRecord, key, endpoints: &[_]| { + static_credential_binding(Some(sandbox), record, key, endpoints, None, true) + }; + let initial = external_binding("sandbox-a", &record, "API_KEY", &endpoints); + assert!(!initial.workload_credential_handle.is_empty()); + record.resource_version = 2; + record + .provider + .credentials + .insert("API_KEY".to_string(), "synthetic-new".to_string()); + assert!(initial == external_binding("sandbox-a", &record, "API_KEY", &endpoints)); + assert!(initial != external_binding("sandbox-b", &record, "API_KEY", &endpoints)); + assert!(initial != external_binding("sandbox-a", &record, "OTHER_KEY", &endpoints)); + + for (host, port, path) in [ + ("other.example.com", 443, "/v1/**"), + ("api.example.com", 8443, "/v1/**"), + ("api.example.com", 443, "/v2/**"), + ] { + let changed_endpoints = vec![StaticCredentialEndpointBinding { + host: host.to_string(), + port, + path: path.to_string(), + }]; + assert!( + initial.workload_credential_handle + != external_binding("sandbox-a", &record, "API_KEY", &changed_endpoints) + .workload_credential_handle + ); + } + record.object_id = "provider-instance-b".to_string(); + assert!( + initial.workload_credential_handle + != external_binding("sandbox-a", &record, "API_KEY", &endpoints) + .workload_credential_handle, + "recreating the same provider name must revoke the original handle" + ); + assert!( + static_credential_binding(None, &record, "API_KEY", &endpoints, None, true) + .workload_credential_handle + .is_empty(), + "an external handle requires an authenticated sandbox binding" + ); + } + #[test] fn workload_handle_derivation_is_order_independent_and_boundary_bound() { let endpoints = vec![ @@ -11935,6 +12037,91 @@ mod tests { ); } + #[tokio::test] + async fn stable_placeholder_resolve_provider_env_marks_only_bound_profile_credentials() { + let store = test_store().await; + let mut profile = custom_profile("stable-placeholder-test"); + let mut credential = static_credential("assertion", "EXTERNAL_ASSERTION", true); + credential.stable_placeholder = true; + profile.credentials = vec![credential]; + profile.files = vec![openshell_core::proto::ProviderProfileFile { + path: "client.toml".to_string(), + content: "endpoint = 'https://tools.example.com/mcp'".to_string(), + env_var: "CLIENT_CONFIG_FILE".to_string(), + }]; + let sources = ProviderProfileSources::from_test_profiles(vec![profile]); + let catalog = sources + .snapshot_catalog(&store, "default") + .await + .expect("test profile catalog"); + create_provider_record_with_catalog( + &store, + &catalog, + "default", + provider_with_credential_value( + "stable-provider", + "stable-placeholder-test", + "EXTERNAL_ASSERTION", + "assertion-v1", + ), + ) + .await + .expect("provider with stable credential"); + + let records = + load_provider_environment_records(&store, "default", &["stable-provider".to_string()]) + .await + .expect("provider records"); + let policy_bindings = HashMap::from([( + "stable-provider".to_string(), + vec![StaticCredentialEndpointBinding { + host: "tools.example.com".to_string(), + port: 443, + path: "/mcp/**".to_string(), + }], + )]); + let credentials = crate::credentials::CredentialRuntime::from_config( + &openshell_core::Config::new(None).with_credential_drivers(["test-static"]), + ) + .expect("test credential runtime"); + let result = + resolve_provider_environment_from_records_with_policy_bindings_and_credentials( + &store, + &catalog, + &records, + &policy_bindings, + &credentials, + Some("sandbox-id"), + ) + .await + .expect("provider environment"); + + assert_eq!( + result.environment.get("CLIENT_CONFIG_FILE"), + Some(&"/run/openshell/providers/stable-provider/client.toml".to_string()), + ); + assert_eq!( + result + .files + .get("/run/openshell/providers/stable-provider/client.toml"), + Some(&"endpoint = 'https://tools.example.com/mcp'".to_string()), + ); + assert_eq!( + result + .stable_placeholder_environment_keys + .into_iter() + .collect::>(), + vec!["EXTERNAL_ASSERTION".to_string()] + ); + assert_eq!( + result.static_credential_bindings["EXTERNAL_ASSERTION"] + .workload_credential_handle + .len(), + 64, + "external stable placeholders must use an identity-bound handle", + ); + } + #[tokio::test] async fn resolve_provider_env_binds_endpointless_credential_handle_only_from_policy() { let store = test_store().await; diff --git a/crates/openshell-server/src/grpc/provider_readiness.rs b/crates/openshell-server/src/grpc/provider_readiness.rs index 3de9148b27..5c15f8a2ea 100644 --- a/crates/openshell-server/src/grpc/provider_readiness.rs +++ b/crates/openshell-server/src/grpc/provider_readiness.rs @@ -259,8 +259,11 @@ async fn load_current_snapshot( ) -> Result<(ProviderDesiredIdentity, ProviderReadinessReason), Status> { let mut desired = current_target_identity(state, sandbox, provider_name).await?; let config = super::policy::load_sandbox_config(state, sandbox).await?; + // This is the desired gateway snapshot, not a supervisor delivery. Include + // all supported credential forms; the supervisor's applied report proves + // whether it installed that snapshot with its negotiated capabilities. let environment = - super::policy::load_sandbox_provider_environment(state, sandbox, true).await?; + super::policy::load_sandbox_provider_environment(state, sandbox, true, true).await?; let current = state .store .get_message::(sandbox.object_id()) diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index 7c96f81787..dd89406cb7 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -136,6 +136,9 @@ mod tests { // Driver-operation ownership adds pending and a retained operation ID to // SandboxProvisioning in both closures. Old rows decode false and empty; // decoding or deadline updates cannot claim an existing attempt. + // Stable placeholders add a stored credential flag whose absent value is + // false. Capability negotiation and delivered key lists are public-only; + // the set of shared message and enum types remains unchanged. // SessionRedirect and SupervisorHello.redirected are supervisor control // traffic and are never stored. // Token-grant owner fields add gateway-derived metadata to endpoints and @@ -144,11 +147,14 @@ mod tests { // from effective policy rather than trusting persisted owner stamps. // Operator credential export adds four public messages and no durable types. const PUBLIC_RPC_SCHEMA_SHA256: &str = - "f986011b0ced61066dd0787dd86ca50f44c621ce51abc885eef2835160a71109"; + "5ae8334d9f6212a00c38ba965ff62e567d2846b15f4df8faf85954023da542e7"; const DURABLE_SCHEMA_SHA256: &str = - "96269474903e077df4d4861db0dd1004b8a7205604ffadcdaff98d0124f18147"; + "5281143e2825539af6148785c91f9df67f654eb74d215f57940af9314867a55f"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = "761dea31a521b0650840fe2a823ad6e36a265ed323ba4506889781d630df0ee3"; + // Encoded with the schema that omitted stable_placeholder. Keep these + // bytes fixed so the current encoder cannot hide a compatibility change. + const LEGACY_STATIC_PROFILE: &str = "0a240a116c65676163792d70726f66696c652d6964120d6c65676163792d737461746963280712510a0d6c65676163792d73746174696312184c6567616379207374617469632063726564656e7469616c2a260a076170695f6b65791a1153594e5448455449435f4150495f4b455920012a06626561726572"; // A persisted Sandbox without endpoint status retains its lifecycle fields; // the absent repeated field decodes empty and needs no database rewrite. const SANDBOX_WITHOUT_ENDPOINT_STATUS: &str = "0a1e0a0a73616e64626f782d6964120773616e64626f783a0764656661756c741a2b0a0773616e64626f782a0d0a05526561647912045472756530023807420d73757065727669736f722d6964"; @@ -683,6 +689,23 @@ mod tests { hex::decode(encoded).expect("checked-in legacy fixture must be valid hex") } + #[test] + fn legacy_stored_profile_preserves_revision_scoped_credentials() { + let bytes = legacy_bytes(LEGACY_STATIC_PROFILE); + let stored = StoredProviderProfile::decode(bytes.as_slice()) + .expect("legacy static profile must decode"); + assert_eq!(stored.encode_to_vec(), bytes); + let profile = stored.profile.expect("profile"); + assert_eq!(profile.id, "legacy-static"); + assert_eq!(profile.credentials.len(), 1); + let credential = &profile.credentials[0]; + assert_eq!(credential.name, "api_key"); + assert_eq!(credential.env_vars, ["SYNTHETIC_API_KEY"]); + assert!(credential.required); + assert_eq!(credential.auth_style, "bearer"); + assert!(!credential.stable_placeholder); + } + #[test] fn sandbox_payload_without_endpoint_status_decodes() { use openshell_core::proto::{Sandbox, SandboxPhase}; diff --git a/crates/openshell-supervisor/src/lib.rs b/crates/openshell-supervisor/src/lib.rs index 7f33e80475..aa6d8e36e5 100644 --- a/crates/openshell-supervisor/src/lib.rs +++ b/crates/openshell-supervisor/src/lib.rs @@ -2787,13 +2787,14 @@ fn provider_environment_is_installable(reason: ProviderReadinessReason) -> bool fn prepare_provider_environment( provider: &openshell_core::grpc_client::ProviderEnvironmentResult, ) -> Result { - let prepared = ProviderCredentialState::from_bound_environment( + let prepared = ProviderCredentialState::from_bound_environment_with_stable_placeholders( provider.provider_env_revision, provider.environment.clone(), provider.credential_expires_at_ms.clone(), provider.dynamic_credentials.clone(), provider.static_credential_bindings.clone(), provider.non_secret_environment_keys.clone(), + provider.stable_placeholder_environment_keys.clone(), ) .map_err(|_| miette::miette!("Provider credential bindings are invalid"))?; prepared.set_managed_files(provider.files.clone()); @@ -3416,13 +3417,14 @@ fn initial_provider_credentials( ); } let dynamic_credentials_fallback = result.dynamic_credentials.clone(); - match ProviderCredentialState::from_bound_environment( + match ProviderCredentialState::from_bound_environment_with_stable_placeholders( result.provider_env_revision, result.environment, result.credential_expires_at_ms, result.dynamic_credentials, result.static_credential_bindings, result.non_secret_environment_keys, + result.stable_placeholder_environment_keys, ) { Ok(credentials) => { credentials.set_managed_files(result.files); @@ -3459,6 +3461,21 @@ fn initial_provider_credentials( /// the existing bounded retry, but failures never delay policy enforcement. #[tonic::async_trait] trait PolicyGatewayClient: Clone + Send + Sync + 'static { + /// Refresh credentials while preserving the activated delivery requirement. + async fn fetch_provider_environment( + &self, + endpoint: &str, + sandbox_id: &str, + requires_stable_placeholders: bool, + ) -> Result { + openshell_core::grpc_client::fetch_provider_environment_with_stable_placeholders( + endpoint, + sandbox_id, + requires_stable_placeholders, + ) + .await + } + async fn poll_settings( &self, sandbox: &str, @@ -3480,15 +3497,6 @@ trait PolicyGatewayClient: Clone + Send + Sync + 'static { Ok(()) } - /// Fetch the complete provider snapshot through the ordinary static-binding API. - async fn fetch_provider_environment( - &self, - endpoint: &str, - sandbox_id: &str, - ) -> Result { - openshell_core::grpc_client::fetch_provider_environment(endpoint, sandbox_id).await - } - async fn refresh_installed_extension_credentials(&self) -> Result<()> { Ok(()) } @@ -4492,7 +4500,11 @@ async fn run_policy_poll_loop_with_client( ProviderReadinessReason::WaitingForCredentials, ); let provider = match client - .fetch_provider_environment(&ctx.endpoint, &ctx.sandbox_id) + .fetch_provider_environment( + &ctx.endpoint, + &ctx.sandbox_id, + ctx.provider_credentials.requires_stable_placeholders(), + ) .await { Ok(provider) @@ -4563,14 +4575,17 @@ async fn run_policy_poll_loop_with_client( ); // Repeat the rejected binding validation on the live state to // revoke static material and retain the fetched dynamic grants. - let _ = ctx.provider_credentials.install_bound_environment( - provider.provider_env_revision, - provider.environment, - provider.credential_expires_at_ms, - provider.dynamic_credentials, - provider.static_credential_bindings, - provider.non_secret_environment_keys, - ); + let _ = ctx + .provider_credentials + .install_bound_environment_with_stable_placeholders( + provider.provider_env_revision, + provider.environment, + provider.credential_expires_at_ms, + provider.dynamic_credentials, + provider.static_credential_bindings, + provider.non_secret_environment_keys, + provider.stable_placeholder_environment_keys, + ); endpoint_status::reset( ctx.endpoint_observation_tx.as_ref(), current_endpoint_policy.as_ref(), @@ -6987,6 +7002,7 @@ network_policies: dynamic_credentials: std::collections::HashMap::new(), static_credential_bindings: std::collections::HashMap::new(), non_secret_environment_keys: Vec::new(), + stable_placeholder_environment_keys: Vec::new(), } } @@ -7140,6 +7156,7 @@ network_policies: &self, _endpoint: &str, _sandbox_id: &str, + _requires_stable_placeholders: bool, ) -> Result { let identity = self.environment_identity.lock().unwrap().clone(); let mut provider = startup_provider(identity.revision); @@ -7187,9 +7204,10 @@ network_policies: &self, endpoint: &str, sandbox_id: &str, + requires_stable_placeholders: bool, ) -> Result { self.inner - .fetch_provider_environment(endpoint, sandbox_id) + .fetch_provider_environment(endpoint, sandbox_id, requires_stable_placeholders) .await } @@ -7260,6 +7278,7 @@ network_policies: dynamic_credentials: HashMap::new(), static_credential_bindings: HashMap::new(), non_secret_environment_keys: Vec::new(), + stable_placeholder_environment_keys: Vec::new(), }; if let Some(value) = value { result @@ -7318,9 +7337,89 @@ network_policies: ); } - type ProviderFetchRequest = tokio::sync::oneshot::Sender< - Result, - >; + fn stable_provider_environment( + revision: u64, + value: Option<&str>, + ) -> openshell_core::grpc_client::ProviderEnvironmentResult { + let mut result = static_provider_environment(revision, value); + if let Some(binding) = result.static_credential_bindings.get_mut("EXTERNAL_TOKEN") { + binding.workload_credential_handle = "a".repeat(64); + result + .stable_placeholder_environment_keys + .push("EXTERNAL_TOKEN".into()); + } + result + } + + #[test] + fn initial_provider_credentials_preserve_stable_delivery_requirement() { + let readiness = ProviderReadinessTracker::new(); + let policy = proto_policy_fixture(); + let mut startup = settings_poll_result( + Some(policy.clone()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + startup.provider_env_revision = 1; + let (_, _, prepared) = prepare_startup_configuration( + &startup, + &policy, + &stable_provider_environment(1, Some("initial")), + ) + .expect("stable credentials prepare for admitted startup"); + assert!(prepared.requires_stable_placeholders()); + let state = initial_provider_credentials( + stable_provider_environment(1, Some("initial")), + &readiness, + ); + assert!(state.requires_stable_placeholders()); + assert!(readiness.observation(&state).credentials_installed); + let (revision, child_env) = state.child_env_snapshot_with_gcp_resolved().unwrap(); + assert_eq!(prepared.snapshot().child_env, child_env); + let reference = &child_env["EXTERNAL_TOKEN"]; + assert_eq!(revision, 1); + assert_eq!( + reference, + &format!("openshell:resolve:env:s{}_EXTERNAL_TOKEN", "a".repeat(64)) + ); + assert_eq!( + state + .resolver_for_endpoint("tools.example.com", 443, "/v1/chat") + .unwrap() + .resolve_placeholder(reference), + Some("initial"), + ); + // The remote workload receives the prepared snapshot, with no resolver + // authority and no second placeholder transformation at its boundary. + let boundary = + ProviderCredentialState::from_child_env_snapshot(revision, child_env.clone()); + assert_eq!(boundary.snapshot().child_env, child_env); + assert!(boundary.resolver().is_none()); + + let mut invalid = stable_provider_environment(2, Some("invalid")); + invalid + .static_credential_bindings + .get_mut("EXTERNAL_TOKEN") + .unwrap() + .workload_credential_handle + .clear(); + startup.provider_env_revision = 2; + assert!(prepare_startup_configuration(&startup, &policy, &invalid).is_err()); + let rejected = initial_provider_credentials(invalid, &readiness); + assert!(rejected.snapshot().child_env.is_empty()); + assert!(rejected.resolver().is_none()); + assert_eq!( + readiness.observation(&rejected).reason, + i32::from(ProviderReadinessReason::CredentialInstallFailed) + ); + } + + type ProviderFetchRequest = ( + bool, + tokio::sync::oneshot::Sender< + Result, + >, + ); #[derive(Clone)] struct ScriptedProviderGateway { @@ -7353,11 +7452,12 @@ network_policies: &self, _endpoint: &str, sandbox_id: &str, + requires_stable_placeholders: bool, ) -> Result { assert_eq!(sandbox_id, "sandbox-test"); let (response, received) = tokio::sync::oneshot::channel(); self.requests - .send(response) + .send((requires_stable_placeholders, response)) .map_err(|_| miette::miette!("provider request channel closed"))?; received .await @@ -7491,9 +7591,10 @@ network_policies: &self, endpoint: &str, sandbox_id: &str, + requires_stable_placeholders: bool, ) -> Result { self.inner - .fetch_provider_environment(endpoint, sandbox_id) + .fetch_provider_environment(endpoint, sandbox_id, requires_stable_placeholders) .await } @@ -7590,7 +7691,7 @@ network_policies: expect_policy_report(&mut reports, 1).await; polls.send(initial.clone()).unwrap(); - let response = timeout(Duration::from_secs(1), received.recv()) + let (_, response) = timeout(Duration::from_secs(1), received.recv()) .await .unwrap() .unwrap(); @@ -7611,7 +7712,7 @@ network_policies: let failed_id = credentials.snapshot().installation_id.clone(); polls.send(initial.clone()).unwrap(); - let response = timeout(Duration::from_secs(1), received.recv()) + let (_, response) = timeout(Duration::from_secs(1), received.recv()) .await .unwrap() .unwrap(); @@ -7642,7 +7743,7 @@ network_policies: changed.config_revision = 200; changed.policy_hash = "hash-v2".to_string(); polls.send(changed).unwrap(); - let response = timeout(Duration::from_secs(1), received.recv()) + let (_, response) = timeout(Duration::from_secs(1), received.recv()) .await .unwrap() .unwrap(); @@ -7756,7 +7857,7 @@ network_policies: ); changed.provider_env_revision = 1; polls.send(changed).unwrap(); - let response = timeout(Duration::from_secs(1), received.recv()) + let (_, response) = timeout(Duration::from_secs(1), received.recv()) .await .expect("provider refresh requested") .expect("poll loop active"); @@ -7823,7 +7924,7 @@ network_policies: let mut poll = initial.clone(); poll.provider_env_revision = revision; polls.send(poll).unwrap(); - let response = timeout(Duration::from_secs(5), received.recv()) + let (_, response) = timeout(Duration::from_secs(5), received.recv()) .await .expect("provider refresh requested") .expect("poll loop active"); @@ -7874,6 +7975,91 @@ network_policies: let _ = task.await; } + #[tokio::test] + async fn provider_poll_preserves_stable_delivery_across_rotation_failure_and_detach() { + let engine = Arc::new(OpaEngine::from_proto(&proto_policy_fixture()).unwrap()); + let initial = settings_poll_result( + Some(proto_policy_fixture()), + 1, + openshell_core::proto::PolicySource::Sandbox, + ); + let ctx = policy_poll_test_context( + engine, + LoadedPolicyOrigin::Gateway { + revision: Some(LoadedPolicyRevision::from_snapshot(&initial)), + has_last_valid_policy: true, + }, + default_middleware_connector(), + ); + let state = ctx.provider_credentials.clone(); + let tracker = ctx.provider_readiness.clone(); + let (policy, polls, mut reports) = scripted_policy_gateway(); + let (requests, mut received) = tokio::sync::mpsc::unbounded_channel(); + // Startup acknowledges the policy already loaded before the refresh + // loop consumes later provider revisions. + polls.send(initial.clone()).unwrap(); + let task = tokio::spawn(run_policy_poll_loop_with_client( + ctx, + ScriptedProviderGateway { policy, requests }, + )); + expect_policy_report(&mut reports, 1).await; + let reference = format!("openshell:resolve:env:s{}_EXTERNAL_TOKEN", "a".repeat(64)); + + // A failed refresh must remain eligible for retry at the same revision, + // carrying the prior support requirement despite revoking its secrets. + for (revision, value, fail, required_before, required_after) in [ + (1, Some("initial"), false, false, true), + (2, Some("rotated"), false, true, true), + (3, None, true, true, true), + (3, Some("recovered"), false, true, true), + (4, None, false, true, false), + (5, None, false, false, false), + ] { + let mut poll = initial.clone(); + poll.provider_env_revision = revision; + polls.send(poll).unwrap(); + let (required, response) = timeout(Duration::from_secs(5), received.recv()) + .await + .expect("provider refresh requested") + .expect("poll loop active"); + assert_eq!(required, required_before, "revision {revision}"); + let result = if fail { + Err(miette::miette!("gateway support acknowledgment missing")) + } else { + Ok(stable_provider_environment(revision, value)) + }; + assert!(response.send(result).is_ok()); + timeout(Duration::from_secs(5), async { + loop { + let resolved = state + .resolver_for_endpoint("tools.example.com", 443, "/v1/chat") + .and_then(|resolver| { + resolver.resolve_placeholder(&reference).map(str::to_owned) + }); + let observed = tracker.observation(&state); + if state.revision() == revision + && resolved.as_deref() == value + && (fail || observed.credentials_installed) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("provider snapshot installed or revoked"); + assert_eq!(state.requires_stable_placeholders(), required_after); + if value.is_some() { + assert_eq!(state.snapshot().child_env["EXTERNAL_TOKEN"], reference); + } else { + assert!(state.snapshot().child_env.is_empty()); + assert!(state.resolver().is_none()); + } + } + task.abort(); + let _ = task.await; + } + fn policy_poll_test_context( opa_engine: Arc, loaded_policy_origin: LoadedPolicyOrigin, diff --git a/docs/how-it-works/providers/profiles.mdx b/docs/how-it-works/providers/profiles.mdx index da2eee03d6..7ff7d7c8e6 100644 --- a/docs/how-it-works/providers/profiles.mdx +++ b/docs/how-it-works/providers/profiles.mdx @@ -367,6 +367,9 @@ credentials: description: API access token env_vars: [CUSTOM_API_TOKEN] required: true + # Optional mode for external credential updaters that need one opaque + # placeholder to survive value-only provider revisions. + stable_placeholder: false # Accepted values: basic, bearer, header, query, path. # These fields describe static credential placement. @@ -750,6 +753,22 @@ OpenShell skips expired provider credentials when it builds a sandbox provider e The gateway sends a complete host, port, and path binding for every emitted static credential key. It derives bindings from profile endpoints or explicit sandbox policy endpoints for endpointless profiles. It withholds static credential keys from endpointless selected profiles that have no explicit policy binding. Supervisors reject other incomplete binding metadata and clear previously active provider material when a refresh fails validation. Refer to [Static Credential Endpoint Binding](#understand-static-credential-endpoint-binding) for matching, denial, lifecycle, and migration behavior. +### Keep an Externally Updated Placeholder Stable + +An MCP client may configure its caller-assertion header once at startup using the reference supplied by OpenShell. A host service rotates the assertion and updates the provider before the old assertion expires. The client needs its next request to use the new assertion without restarting or reloading its environment reference. Gateway-managed token refresh already provides this continuity; `stable_placeholder` extends it to externally supplied static credentials. + +Set `stable_placeholder: true` on a credential only when an external updater requires the workload to keep one opaque placeholder across value-only `provider update` revisions. Declare at least one `env_vars` alias for that credential. OpenShell still classifies the value as secret and resolves it only for hosts, ports, and paths in the credential's effective endpoint binding. An endpointless profile therefore needs an explicit sandbox-policy `credential_binding` before the gateway emits this metadata. + +The setting defaults to `false`, preserving existing revision-scoped behavior. Ordinary credentials continue to work with older gateways. To enable stable placeholders, use a supporting gateway, supervisor, and profile client. The CLI and Go SDK check gateway support before importing or updating an opted-in profile; a failed support check leaves the profile unchanged. Raw API clients must check `LintProviderProfilesResponse.supports_stable_placeholder` before enabling the setting. + +The gateway withholds opted-in credentials from supervisors that do not advertise support. Once a supervisor activates an external stable credential, a gateway response without support acknowledgment revokes static credentials instead of switching to revision-scoped delivery. This requirement survives failed refreshes and clears when an acknowledged snapshot removes the last external stable credential. Keep a supporting gateway for startup and supervisor reconstruction: older binaries can discard unknown profile fields, so an empty stable-key list on first contact does not prove support. Remove the opt-in and activate that change before downgrading the gateway. + +Use the environment value issued by the supervisor. Do not construct a resolver string or a canonical environment-name alias. Once the supervisor installs a value update, the next request from a process holding its original placeholder resolves the current value. Saving the provider update alone does not establish supervisor activation. Expiry or an activated detach prevents the placeholder from resolving a credential. + +The supervisor resolves the placeholder outside the workload. The sandbox receives the prepared environment through its authenticated boundary; the credential value remains with the supervisor. + +The opaque placeholder is tied to the sandbox, provider instance, credential key, and effective endpoint binding. Updating only the credential value preserves the placeholder. Replacing the provider or changing its endpoint binding produces a new placeholder, so a running process cannot use its old placeholder for the replacement credential. Use the default `false` when value-only continuity is unnecessary. Profiles cannot combine `stable_placeholder: true` with gateway-managed refresh, credentials produced as another credential's refresh outputs, or `token_grant` because those credentials have a different lifecycle owner. + ## Configure Credential Refresh Refresh configuration is stored separately from the current injectable credential value. Non-secret refresh configuration remains in the refresh-state record. Secret material is resolved from the active credential driver only while the gateway mints a new short-lived token. The gateway writes the token back through credential storage and updates credential expiry metadata. If an OAuth issuer rotates its refresh token, the gateway stages the replacement through credential storage before committing the refresh generation and removes the previous handle afterward. diff --git a/e2e/rust/tests/stable_external_placeholders.rs b/e2e/rust/tests/stable_external_placeholders.rs new file mode 100644 index 0000000000..16002c21c2 --- /dev/null +++ b/e2e/rust/tests/stable_external_placeholders.rs @@ -0,0 +1,1354 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e-docker")] + +//! External caller assertion rotation through a real sandbox HTTPS request path. +//! +//! The workload sends a synthetic `tools/call` request using one retained +//! supervisor-issued reference in one Python process. The fixture exercises +//! credential substitution, not a complete MCP session or an agent runtime. +//! Only the isolated backend and privileged provider CLI receive synthetic keys; +//! workload files, responses, and diagnostics contain status information only. + +use std::net::Ipv4Addr; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +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::{E2E_WORKLOAD_IMAGE, SandboxGuard}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; +use tokio::time::{Instant, sleep, timeout}; + +const TOKEN_ENV: &str = "STABLE_EXTERNAL_E2E_TOKEN"; +const BACKEND_PORT: u16 = 8443; +const OTHER_BACKEND_PORT: u16 = 8444; +const READY: &str = "stable-placeholder-client-ready"; +const CONTROL: &str = "/sandbox/stable-placeholder-probe"; +const RESULT: &str = "/sandbox/stable-placeholder-result"; +const COMMAND_TIMEOUT: Duration = Duration::from_secs(60); +const IMAGE_BUILD_TIMEOUT: Duration = Duration::from_secs(300); +const READINESS_TIMEOUT_SECONDS: &str = "90"; +const READINESS_COMMAND_TIMEOUT: Duration = Duration::from_secs(105); +const GATEWAY_READY_TIMEOUT: Duration = Duration::from_secs(120); + +// Keys arrive on a private stdin pipe after process creation. Neither the +// command line nor any file contains them, and HTTP/server errors are silent. +const BACKEND: &str = r" +import http.server, json, ssl, sys, threading + +sys.excepthook = lambda *_: None +config = json.loads(sys.stdin.readline()) +keys = config.pop('keys') +lock = threading.Lock() +state = {'phase': 0, 'total': 0, 'accepted': [0, 0], 'rejected': 0, + 'received': [0, 0], 'missing': 0, 'unknown': 0, 'invalid_tool_calls': 0} + +class Server(http.server.ThreadingHTTPServer): + daemon_threads = True + def handle_error(self, request, client_address): + pass + +class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): + pass + def do_POST(self): + request = json.loads(self.rfile.read(int(self.headers.get('Content-Length', '0')))) + tool_call = (request.get('jsonrpc') == '2.0' and request.get('method') == 'tools/call' + and request.get('params') == {'name': 'local_hello', + 'arguments': {'name': 'rotation-proof'}}) + with lock: + phase = state['phase'] + assertion = self.headers.get('x-openshell-sandbox-assertion') + generation = 'missing' if assertion is None else 'unknown' + for index, key in enumerate(keys): + if assertion == key: + generation = 'A' if index == 0 else 'B' + state['received'][index] += 1 + if generation in ('missing', 'unknown'): + state[generation] += 1 + if not tool_call: + state['invalid_tool_calls'] += 1 + valid = assertion == keys[phase] and tool_call + state['total'] += 1 + if valid: + state['accepted'][phase] += 1 + else: + state['rejected'] += 1 + response = {'jsonrpc': '2.0', 'id': request.get('id'), + 'fixture': {'authorized': valid, 'phase': phase, + 'credential_generation': generation, 'tool_call': tool_call}} + if valid: + response['result'] = {'content': [{'type': 'text', 'text': 'Hello rotation-proof'}]} + else: + response['error'] = {'code': -32001, 'message': 'Unauthorized'} + body = json.dumps(response).encode() + self.send_response(200 if valid else 401) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(body))) + self.send_header('Connection', 'close') + self.end_headers() + self.wfile.write(body) + +context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +context.minimum_version = ssl.TLSVersion.TLSv1_2 +context.load_cert_chain(config['certificate'], config['private_key']) +servers = [] +for port in config['ports']: + server = Server(('0.0.0.0', port), Handler) + server.socket = context.wrap_socket(server.socket, server_side=True) + threading.Thread(target=server.serve_forever, daemon=True).start() + servers.append(server) +print(json.dumps({'ready': True}), flush=True) + +for line in sys.stdin: + request = json.loads(line) + with lock: + if request['command'] == 'rotate': + state['phase'] = 1 + print(json.dumps(state), flush=True) +for server in servers: + server.shutdown() + server.server_close() +"; + +// This source is safe to place in the workload image: it contains environment +// key names and endpoint coordinates, but never a key value or issued handle. +const CLIENT: &str = r" +import json, os, pathlib, re, ssl, sys, time, urllib.error, urllib.request + +config = json.loads(sys.argv[1]) +key = 'STABLE_EXTERNAL_E2E_TOKEN' +token = os.environ.get(key, '') +pattern = r's[0-9a-f]{64}' if config['stable'] else r'v[1-9][0-9]*' +if not re.fullmatch(r'openshell:resolve:env:' + pattern + '_' + key, token): + print('client did not receive the expected issued reference', flush=True) + sys.exit(64) +pid = os.getpid() +control = pathlib.Path('/sandbox/stable-placeholder-probe') +result = pathlib.Path('/sandbox/stable-placeholder-result') + +def probe(phase): + host, port, path = config['host'], config['port'], '/mcp/local-hello' + authorization = token + target = phase[:-8] if phase.endswith('_control') else phase + if target == 'wrong_host': + host = config['other_host'] + elif target == 'wrong_port': + port = config['other_port'] + elif target == 'wrong_path': + path = '/outside' + elif phase == 'canonical_alias': + authorization = 'openshell:resolve:env:' + key + url = 'https://%s:%s%s' % (host, port, path) + response = {'phase': phase, 'pid': pid, 'same_reference': os.environ.get(key) == token, + 'ok': False, 'status': 0, 'backend_phase': -1, + 'credential_generation': 'unobserved', 'tool_call': False} + try: + context = ssl.create_default_context() + if phase == 'untrusted_ca': + # An empty trust store proves this client does verify TLS. + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + headers = {'Content-Type': 'application/json'} + if not phase.endswith('_control'): + headers['x-openshell-sandbox-assertion'] = authorization + payload = {'jsonrpc': '2.0', 'id': 1, 'method': 'tools/call', + 'params': {'name': 'local_hello', 'arguments': {'name': 'rotation-proof'}}} + request = urllib.request.Request(url, data=json.dumps(payload).encode(), headers=headers) + try: + reply = urllib.request.urlopen(request, context=context, timeout=5) + except urllib.error.HTTPError as error: + # The backend's safe 401 body proves the negative endpoint is + # reachable before a credential-bearing request is denied. + reply = error + with reply: + body = json.loads(reply.read(1024)) + attestation = body.get('fixture', {}) + response['ok'] = (attestation.get('authorized') is True + and body.get('result', {}).get('content') == + [{'type': 'text', 'text': 'Hello rotation-proof'}]) + response['status'] = reply.status + response['backend_phase'] = attestation.get('phase', -1) + response['credential_generation'] = attestation.get('credential_generation', 'unobserved') + response['tool_call'] = attestation.get('tool_call') is True + except Exception as error: + # HTTP/TLS exception text can embed headers. Type names expose the + # failure layer without retaining messages, headers, or credentials. + response['error_kind'] = type(error).__name__ + response['reason_kind'] = type(getattr(error, 'reason', None)).__name__ + return response + +if len(sys.argv) > 2 and sys.argv[2] == '--once': + print(json.dumps(probe('fresh')), flush=True) + sys.exit(0) + +print('stable-placeholder-client-ready', flush=True) +deadline = time.monotonic() + 600 +while time.monotonic() < deadline: + if control.exists(): + phase = control.read_text().strip() + control.unlink() + temporary = result.with_suffix('.tmp') + temporary.write_text(json.dumps(probe(phase))) + temporary.replace(result) + time.sleep(0.05) +"; + +struct FixtureImage { + engine: ContainerEngine, + tag: String, +} + +impl FixtureImage { + fn new() -> Result { + Ok(Self { + engine: ContainerEngine::from_env()?, + tag: format!( + "localhost/openshell-e2e-stable-{}-{:016x}:latest", + std::process::id(), + rand::random::(), + ), + }) + } + + fn tag(&self) -> &str { + &self.tag + } + + async fn build(&self, dockerfile: &Path, context: &Path) -> Result<(), String> { + let mut command = Command::from(self.engine.command()); + command + .args(["build", "--file"]) + .arg(dockerfile) + .args(["--tag", &self.tag]) + .arg(context); + checked_command_with_timeout(&mut command, "build fixture image", IMAGE_BUILD_TIMEOUT) + .await + .map(|_| ()) + } + + async fn remove(&self) -> Result<(), String> { + let mut command = Command::from(self.engine.command()); + command.args(["image", "rm", "--force", &self.tag]); + // Teardown is explicit and bounded. This type has no Drop subprocess + // that could block the test runtime after the removal deadline expires. + checked_command(&mut command, "remove fixture image") + .await + .map(|_| ()) + } +} + +// These tests run serially against a wrapper-owned gateway. The public fixture +// CA must be removed and the original supervisor restored before the next case. +struct GatewayTrustFixture { + directory: PathBuf, + supervisor_image: String, + health_port: u16, + restore_required: bool, +} + +impl GatewayTrustFixture { + fn load() -> Result { + if std::env::var_os("OPENSHELL_GATEWAY_ENDPOINT").is_some() + || std::env::var_os("OPENSHELL_E2E_GATEWAY_BIN").is_none() + || std::env::var("OPENSHELL_E2E_DRIVER").as_deref() != Ok("docker") + || std::env::var("OPENSHELL_E2E_EXTERNAL_COMPUTE_DRIVER") + .is_ok_and(|value| value != "0") + { + return Err("stable placeholder fixture requires a wrapper-owned gateway with the bundled Docker driver".to_string()); + } + let args_file = std::env::var_os("OPENSHELL_E2E_GATEWAY_ARGS_FILE") + .ok_or("managed gateway argument metadata is missing")?; + let directory = Path::new(&args_file) + .parent() + .ok_or("managed gateway arguments have no parent directory")? + .to_path_buf(); + let raw = + std::fs::read(args_file).map_err(|_| "could not read managed gateway arguments")?; + let args = raw + .split(|byte| *byte == 0) + .filter(|arg| !arg.is_empty()) + .map(std::str::from_utf8) + .collect::, _>>() + .map_err(|_| "managed gateway arguments were not UTF-8")?; + let argument = |name| { + let mut values = args.windows(2).filter(|pair| pair[0] == name); + let value = values.next().ok_or("managed gateway argument is missing")?[1]; + if values.next().is_some() { + return Err("managed gateway argument is duplicated"); + } + Ok(value) + }; + let health_port = argument("--health-port")? + .parse::() + .map_err(|_| "managed gateway health port is invalid")?; + let supervisor_image = std::env::var("OPENSHELL_SUPERVISOR_IMAGE") + .map_err(|_| "managed supervisor image is missing")?; + Ok(Self { + directory, + supervisor_image, + health_port, + restore_required: false, + }) + } + + async fn apply(&mut self, image: &str) -> Result<(), String> { + self.restore_required = true; + restart_fixture_gateway(self.health_port, Some(image)).await + } + + async fn restore(&mut self) -> Result<(), String> { + if !self.restore_required { + return Ok(()); + } + restart_fixture_gateway(self.health_port, None) + .await + .map_err(|_| "could not restore original gateway runtime")?; + self.restore_required = false; + Ok(()) + } +} + +async fn restart_fixture_gateway( + health_port: u16, + supervisor_image: Option<&str>, +) -> Result<(), String> { + let mut gateway = ManagedGateway::from_env() + .map_err(|_| "could not load managed gateway restart metadata")? + .ok_or("managed gateway restart metadata disappeared")?; + if let Some(image) = supervisor_image { + gateway.set_supervisor_image(image); + } + gateway + .stop() + .map_err(|_| "could not stop fixture gateway")?; + gateway + .start() + .map_err(|_| "could not restart fixture gateway")?; + let url = format!("http://127.0.0.1:{health_port}/healthz"); + let deadline = Instant::now() + GATEWAY_READY_TIMEOUT; + loop { + if checked_command( + Command::new("curl").args(["--silent", "--fail", "--max-time", "2", &url]), + "check fixture gateway health", + ) + .await + .is_ok() + { + return Ok(()); + } + if Instant::now() >= deadline { + return Err("fixture gateway did not become healthy".to_string()); + } + sleep(Duration::from_millis(250)).await; + } +} + +struct Backend { + engine: ContainerEngine, + name: String, + network: String, + namespace: String, + child: Option, + input: Option, + output: Option>>, + launch_attempted: bool, +} + +impl Backend { + fn new(name: String) -> Result { + Ok(Self { + engine: ContainerEngine::from_env()?, + name, + network: e2e_network_name().ok_or("fixture requires the managed Docker network")?, + namespace: std::env::var("OPENSHELL_E2E_SANDBOX_NAMESPACE") + .map_err(|_| "fixture requires the managed Docker namespace")?, + child: None, + input: None, + output: None, + launch_attempted: false, + }) + } + + async fn spawn(&mut self, base: &str, tls_directory: &Path) -> Result { + let tls_directory = tls_directory + .to_str() + .filter(|path| !path.contains([',', '\n', '\r'])) + .ok_or("fixture TLS mount path is invalid")?; + let mount = format!("type=bind,src={tls_directory},dst=/fixture-tls,readonly"); + let namespace_label = format!("openshell.ai/sandbox-namespace={}", self.namespace); + let mut command = Command::from(self.engine.command()); + command + .args([ + "run", + "--rm", + "--interactive", + "--pull=never", + "--name", + &self.name, + "--network", + &self.network, + "--label", + "openshell.ai/managed-by=openshell", + "--label", + &namespace_label, + "--label", + "openshell.ai/isolation-role=fixture", + "--read-only", + "--cap-drop=ALL", + "--security-opt=no-new-privileges:true", + "--mount", + &mount, + "--entrypoint", + "/usr/bin/python3", + base, + "-u", + "-c", + BACKEND, + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + // Record the unique container name before creation. Every Result exit + // removes it, including a failed readiness exchange after Docker starts. + // Wrapper-scoped labels also let wrapper teardown reap an interrupted run. + self.launch_attempted = true; + let child = command + .spawn() + .map_err(|_| "could not start synthetic HTTPS backend".to_string())?; + self.child = Some(child); + let child = self.child.as_mut().ok_or("backend child was absent")?; + self.input = Some( + child + .stdin + .take() + .ok_or("backend stdin was not available")?, + ); + let output = child + .stdout + .take() + .ok_or("backend stdout was not available")?; + self.output = Some(BufReader::new(output).lines()); + // The host-networked supervisor cannot resolve Docker bridge aliases. + // Python waits for configuration on stdin while we inspect its bridge + // address and generate a certificate for that exact IP. + self.address().await + } + + async fn address(&mut self) -> Result { + let deadline = Instant::now() + COMMAND_TIMEOUT; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err("backend bridge address did not become available".to_string()); + } + let mut inspect = Command::from(self.engine.command()); + inspect.args([ + "inspect", + "--format", + "{{json .NetworkSettings.Networks}}", + &self.name, + ]); + if let Ok(output) = checked_command_with_timeout( + &mut inspect, + "inspect backend bridge address", + remaining.min(Duration::from_secs(2)), + ) + .await + { + let networks: Value = serde_json::from_str(&output) + .map_err(|_| "backend network metadata was invalid")?; + if let Some(address) = networks[&self.network]["IPAddress"] + .as_str() + .filter(|address| !address.is_empty()) + { + let address = address + .parse::() + .map_err(|_| "backend network address was not IPv4")?; + if !address.is_private() { + return Err("fixture backend requires a private bridge address".to_string()); + } + return Ok(address.to_string()); + } + } + if self + .child + .as_mut() + .ok_or("backend child was absent")? + .try_wait() + .map_err(|_| "could not inspect backend client status")? + .is_some() + { + return Err("backend exited before its bridge address was available".to_string()); + } + sleep(Duration::from_millis(100)).await; + } + } + + async fn initialize(&mut self, config: &Value) -> Result<(), String> { + if self.exchange(config).await?["ready"] != true { + return Err("synthetic HTTPS backend did not become ready".to_string()); + } + Ok(()) + } + + async fn exchange(&mut self, request: &Value) -> Result { + let mut bytes = + serde_json::to_vec(request).map_err(|_| "backend request encoding failed")?; + bytes.push(b'\n'); + let input = self.input.as_mut().ok_or("backend stdin was absent")?; + let output = self.output.as_mut().ok_or("backend stdout was absent")?; + timeout(COMMAND_TIMEOUT, async { + input + .write_all(&bytes) + .await + .map_err(|_| "backend control write failed")?; + let line = output + .next_line() + .await + .map_err(|_| "backend control read failed")? + .ok_or("backend closed its control stream")?; + serde_json::from_str(&line).map_err(|_| "backend status was not valid JSON") + }) + .await + .map_err(|_| "backend control operation timed out".to_string())? + .map_err(str::to_string) + } + + async fn stop(&mut self) -> Result<(), String> { + // Closing stdin lets Python shut down normally. Removing the named + // container also covers a stalled startup or disconnected Docker client; + // killing the attached client alone cannot prove the container stopped. + drop(self.input.take()); + let mut reap_result = Ok(()); + if let Some(child) = self.child.as_mut() { + reap_result = timeout(COMMAND_TIMEOUT, child.kill()) + .await + .map_err(|_| "backend client teardown timed out".to_string()) + .and_then(|result| { + result.map_err(|_| "backend client teardown failed".to_string()) + }); + } + if !self.launch_attempted { + return reap_result; + } + let mut remove = Command::from(self.engine.command()); + remove.args(["rm", "--force", &self.name]); + let removed = checked_command(&mut remove, "remove synthetic backend container").await; + // --rm may have already removed a normally exited backend. Only a + // successful exact-name listing can establish absence after rm fails. + if removed.is_err() { + let mut list = Command::from(self.engine.command()); + let filter = format!("name=^/{}$", self.name); + list.args(["ps", "--all", "--quiet", "--filter", &filter]); + if !checked_command(&mut list, "verify synthetic backend removal") + .await? + .trim() + .is_empty() + { + return Err("synthetic backend container remained after teardown".to_string()); + } + } + reap_result + } +} + +// Counters attest generations without retaining headers or credential values. +#[derive(Deserialize, Serialize)] +struct BackendCounts { + phase: u8, + total: u64, + accepted: [u64; 2], + rejected: u64, + received: [u64; 2], + missing: u64, + unknown: u64, + invalid_tool_calls: u64, +} + +// Distinct bridge addresses provide independent authorized and wrong-host +// endpoints. Summed counters prove denied traffic reaches neither container. +struct BackendPair { + backends: [Backend; 2], +} + +impl BackendPair { + fn new(name: &str) -> Result { + Ok(Self { + backends: [ + Backend::new(format!("{name}-backend"))?, + Backend::new(format!("{name}-other-backend"))?, + ], + }) + } + + async fn spawn(&mut self, base: &str, tls_directory: &Path) -> Result<[String; 2], String> { + let host = self.backends[0].spawn(base, tls_directory).await?; + let other_host = self.backends[1].spawn(base, tls_directory).await?; + if host == other_host { + return Err("wrong-host control requires a distinct backend address".to_string()); + } + Ok([host, other_host]) + } + + async fn initialize(&mut self, config: &Value) -> Result<(), String> { + for backend in &mut self.backends { + backend.initialize(config).await?; + } + Ok(()) + } + + async fn rotate(&mut self) -> Result<(), String> { + for backend in &mut self.backends { + if backend.exchange(&json!({"command": "rotate"})).await?["phase"] != 1 { + return Err("backend did not switch to the replacement assertion".to_string()); + } + } + Ok(()) + } + + async fn counts(&mut self) -> Result { + let mut combined: Option = None; + for backend in &mut self.backends { + let response = backend.exchange(&json!({"command": "snapshot"})).await?; + let counters: BackendCounts = + serde_json::from_value(response).map_err(|_| "backend counters were invalid")?; + if let Some(total) = combined.as_mut() { + if total.phase != counters.phase { + return Err("fixture backends disagree on the active assertion".to_string()); + } + let add = |left: u64, right: u64| { + left.checked_add(right).ok_or("backend counters overflowed") + }; + total.total = add(total.total, counters.total)?; + total.rejected = add(total.rejected, counters.rejected)?; + total.missing = add(total.missing, counters.missing)?; + total.unknown = add(total.unknown, counters.unknown)?; + total.invalid_tool_calls = + add(total.invalid_tool_calls, counters.invalid_tool_calls)?; + for (left, right) in total.accepted.iter_mut().zip(counters.accepted) { + *left = add(*left, right)?; + } + for (left, right) in total.received.iter_mut().zip(counters.received) { + *left = add(*left, right)?; + } + } else { + combined = Some(counters); + } + } + serde_json::to_value(combined.ok_or("backend counters were absent")?) + .map_err(|_| "backend counters could not be encoded".to_string()) + } + + async fn stop(&mut self) -> Result<(), String> { + let mut failures = Vec::new(); + for backend in &mut self.backends { + if let Err(error) = backend.stop().await { + failures.push(error); + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(failures.join("; ")) + } + } +} + +async fn checked_command(command: &mut Command, label: &str) -> Result { + checked_command_with_timeout(command, label, COMMAND_TIMEOUT).await +} + +async fn checked_command_with_timeout( + command: &mut Command, + label: &str, + max_wait: Duration, +) -> Result { + command + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + let output = timeout(max_wait, command.output()) + .await + .map_err(|_| format!("{label} timed out"))? + .map_err(|_| format!("{label} could not start"))?; + if !output.status.success() { + // CLI arguments, captured output, and subprocess errors can contain + // credential material. Error messages expose only the safe operation. + return Err(format!("{label} failed; subprocess output withheld")); + } + String::from_utf8(output.stdout).map_err(|_| format!("{label} returned invalid UTF-8")) +} + +async fn cli(label: &str, args: &[&str], credential: Option<&str>) -> Result { + let mut command = openshell_cmd(); + command.args(args); + if let Some(credential) = credential { + command.env(TOKEN_ENV, credential); + } + checked_command(&mut command, label).await +} + +async fn generate_certificates( + directory: &Path, + host: &str, + other_host: &str, +) -> Result<(PathBuf, PathBuf), String> { + let ca_key = directory.join("ca.key.fixture"); + let ca = directory.join("ca.crt"); + let key = directory.join("backend.key.fixture"); + let csr = directory.join("backend.csr"); + let certificate = directory.join("backend.crt"); + let extensions = directory.join("backend.ext"); + std::fs::write( + &extensions, + format!( + "basicConstraints=critical,CA:FALSE\nsubjectAltName=IP:{host},IP:{other_host}\nextendedKeyUsage=serverAuth\n" + ), + ) + .map_err(|_| "could not write public TLS certificate extensions")?; + checked_command( + Command::new("openssl") + .args([ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-days", + "1", + "-subj", + "/CN=stable-placeholder-e2e-ca", + "-keyout", + ]) + .arg(&ca_key) + .arg("-out") + .arg(&ca), + "generate fixture CA", + ) + .await?; + checked_command( + Command::new("openssl") + .args([ + "req", + "-newkey", + "rsa:2048", + "-nodes", + "-subj", + &format!("/CN={host}"), + "-keyout", + ]) + .arg(&key) + .arg("-out") + .arg(&csr), + "generate fixture TLS key", + ) + .await?; + checked_command( + Command::new("openssl") + .args(["x509", "-req", "-days", "1", "-in"]) + .arg(&csr) + .arg("-CA") + .arg(&ca) + .arg("-CAkey") + .arg(&ca_key) + .arg("-CAcreateserial") + .arg("-extfile") + .arg(&extensions) + .arg("-out") + .arg(&certificate), + "sign fixture TLS certificate", + ) + .await?; + Ok((certificate, key)) +} + +async fn base_binaries(base: &str) -> Result { + let engine = ContainerEngine::from_env()?; + let mut command = Command::from(engine.command()); + command.args([ + "run", "--rm", "--network", "none", "--entrypoint", "/usr/bin/python3", base, + "-c", "import json,os,shutil,sys; print(json.dumps({'python':os.path.realpath(sys.executable),'curl':shutil.which('curl')}))", + ]); + let output = checked_command(&mut command, "inspect fixture image binaries").await?; + let binaries: Value = + serde_json::from_str(&output).map_err(|_| "image binary probe was invalid")?; + for field in ["python", "curl"] { + if !binaries[field] + .as_str() + .is_some_and(|path| Path::new(path).is_absolute()) + { + return Err(format!("fixture image has no absolute {field} executable")); + } + } + Ok(binaries) +} + +fn write_profile( + path: &Path, + name: &str, + host: &str, + port: u16, + python: &str, + stable: bool, +) -> Result<(), String> { + // Omitting the opt-in reproduces the behavior of an existing static + // credential profile on both the upstream gateway and the candidate. + let mut credential = json!({"name": "synthetic_assertion", "env_vars": [TOKEN_ENV], + "required": true, "auth_style": "header", "header_name": "x-openshell-sandbox-assertion"}); + if stable { + credential["stable_placeholder"] = json!(true); + } + let document = json!({ + "id": name, "display_name": "External caller assertion E2E", "category": "other", + "credentials": [credential], + "endpoints": [{"host": host, "port": port, "path": "/mcp/**", "protocol": "rest", + "access": "full", "enforcement": "enforce", + "allowed_ips": ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7"]}], + "binaries": [python], + }); + std::fs::write(path, document.to_string()) + .map_err(|_| "could not write synthetic profile".to_string()) +} + +fn write_policy( + path: &Path, + host: &str, + other_host: &str, + port: u16, + other_port: u16, + python: &str, +) -> Result<(), String> { + // Permit the negative endpoint probes at the network layer so the + // credential binding itself must prevent them from reaching the backend. + // The exact binary allowlist independently denies curl at the valid endpoint. + let endpoints = [(host, port), (host, other_port), (other_host, port)] + .into_iter() + .map(|(host, port)| { + json!({"host": host, "port": port, "path": "/**", "protocol": "rest", + "access": "full", "enforcement": "enforce", + "allowed_ips": ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7"]}) + }) + .collect::>(); + let document = json!({ + "version": 1, + "filesystem_policy": {"include_workdir": false, + "read_only": ["/usr", "/lib", "/proc", "/dev/urandom", "/etc", "/opt", "/var/log"], + "read_write": ["/sandbox", "/tmp", "/dev/null"]}, + "landlock": {"compatibility": "best_effort"}, + "process": {"run_as_user": "sandbox", "run_as_group": "sandbox"}, + "network_policies": {"synthetic_backend": {"name": "synthetic_backend", + "endpoints": endpoints, "binaries": [{"path": python}]}}, + }); + std::fs::write(path, document.to_string()) + .map_err(|_| "could not write synthetic policy".to_string()) +} + +async fn acknowledged_mutation( + args: &[&str], + credential: Option<&str>, + sandbox: &SandboxGuard, + provider: &str, + kind: &str, + keys: &[String; 2], +) -> Result<(), String> { + let mut command = openshell_cmd(); + command.args(args).args([ + "--wait", + "--timeout", + READINESS_TIMEOUT_SECONDS, + "-o", + "json", + ]); + if let Some(credential) = credential { + command.env(TOKEN_ENV, credential); + } + let output = checked_command_with_timeout( + &mut command, + "apply and acknowledge provider mutation", + READINESS_COMMAND_TIMEOUT, + ) + .await?; + if keys.iter().any(|key| output.contains(key)) || output.contains("openshell:resolve:") { + return Err("provider readiness output contained credential material".to_string()); + } + let body: Value = + serde_json::from_str(&output).map_err(|_| "provider readiness output was invalid JSON")?; + let targets = body["targets"] + .as_array() + .ok_or("provider readiness targets were absent")?; + let [status] = targets.as_slice() else { + return Err("provider mutation did not acknowledge exactly one sandbox".to_string()); + }; + let receipt = &status["receipt"]; + let desired = &receipt["desired"]; + let observed = &status["observed"]; + let expected = if kind == "detach" { "revoked" } else { "ready" }; + if body["mutation_id"].as_str().is_none_or(str::is_empty) + || receipt["mutation_id"] != body["mutation_id"] + || receipt["provider"] != provider + || receipt["kind"] != kind + || desired["sandbox"] != sandbox.name + || status["state"] != expected + || status["wait_outcome"] != "complete" + || status["reason"] != "unspecified" + || observed["reason"] != "unspecified" + { + return Err("provider mutation was not acknowledged for the intended sandbox".to_string()); + } + for field in [ + "attachment_epoch", + "provider_env_revision", + "config_revision", + "policy_hash", + ] { + if desired[field].as_str().is_none_or(str::is_empty) || observed[field] != desired[field] { + return Err(format!( + "provider acknowledgment did not match desired {field}" + )); + } + } + for field in [ + "credentials_installed", + "policy_active", + "launch_environment_installed", + ] { + if observed[field] != true { + return Err(format!("provider acknowledgment omitted {field}")); + } + } + Ok(()) +} + +async fn probe(sandbox: &SandboxGuard, phase: &str) -> Result { + // The client consumes the control file as soon as it appears. Publish a + // complete phase atomically so polling cannot observe an empty write. + let command = format!( + "rm -f {RESULT} && printf '%s' '{phase}' > {CONTROL}.tmp && mv {CONTROL}.tmp {CONTROL}" + ); + timeout(COMMAND_TIMEOUT, sandbox.exec(&["sh", "-c", &command])) + .await + .map_err(|_| "client trigger timed out")? + .map_err(|_| "client trigger failed")?; + let deadline = Instant::now() + Duration::from_secs(20); + loop { + if let Ok(Ok(output)) = + timeout(Duration::from_secs(10), sandbox.exec(&["cat", RESULT])).await + { + let response: Value = + serde_json::from_str(output.trim()).map_err(|_| "client result was invalid")?; + if response["phase"] != phase || response["same_reference"] != true { + return Err( + "persistent client changed its retained environment reference".to_string(), + ); + } + return Ok(response); + } + if Instant::now() >= deadline { + return Err(format!("client phase {phase} did not finish")); + } + sleep(Duration::from_millis(100)).await; + } +} + +async fn probe_disallowed_binary( + sandbox: &SandboxGuard, + curl: &str, + host: &str, + port: u16, +) -> Result<(), String> { + // Binary authorization includes allowed ancestors. Launch curl as a + // sibling of the retained Python client, so Python cannot authorize it. + // The shell builtin sends the reference only through curl's stdin pipe. + let script = r#" +test -n "$STABLE_EXTERNAL_E2E_TOKEN" || exit 64 +"$1" --version >/dev/null 2>&1 || exit 65 +if printf 'x-openshell-sandbox-assertion: %s\n' "$STABLE_EXTERNAL_E2E_TOKEN" | \ + "$1" --silent --fail --max-time 5 --output /dev/null --header @- \ + --data '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"local_hello","arguments":{"name":"rotation-proof"}}}' "$2" 2>/dev/null +then + printf 'unexpected-success' +else + printf 'denied' +fi +"#; + let url = format!("https://{host}:{port}/mcp/local-hello"); + let output = timeout( + COMMAND_TIMEOUT, + sandbox.exec(&["sh", "-c", script, "binary-probe", curl, &url]), + ) + .await + .map_err(|_| "independent binary probe timed out")? + .map_err(|_| "independent binary probe failed to execute")?; + if output.trim() != "denied" { + return Err("independent disallowed binary reached the endpoint".to_string()); + } + Ok(()) +} + +fn check( + response: &Value, + pid: u64, + success: bool, + backend_phase: Option, +) -> Result<(), String> { + if response["pid"].as_u64() != Some(pid) || response["ok"].as_bool() != Some(success) { + // Report only typed status fields; never include HTTP error text or + // a serialized response that might later grow a credential field. + return Err(format!( + "client probe failed: pid_matches={}, expected_ok={success}, actual_ok={:?}, status={:?}, error_kind={:?}, reason_kind={:?}", + response["pid"].as_u64() == Some(pid), + response["ok"].as_bool(), + response["status"].as_u64(), + response["error_kind"].as_str(), + response["reason_kind"].as_str(), + )); + } + if let Some(phase) = backend_phase + && (response["backend_phase"].as_u64() != Some(phase) + || response["status"] != 200 + || response["credential_generation"] != if phase == 0 { "A" } else { "B" } + || response["tool_call"] != true) + { + return Err("backend did not attest the expected credential generation".to_string()); + } + Ok(()) +} + +#[tokio::test] +#[serial_test::serial] +async fn persistent_external_placeholder_rotation_and_revocation() -> Result<(), String> { + external_caller_assertion_rotation(true).await +} + +#[tokio::test] +#[serial_test::serial] +async fn ordinary_external_placeholder_keeps_revoked_assertion_after_rotation() -> Result<(), String> +{ + external_caller_assertion_rotation(false).await +} + +// Keep each lifecycle together so no phase can replace the retained client. +#[allow(clippy::too_many_lines)] +async fn external_caller_assertion_rotation(stable: bool) -> Result<(), String> { + let mut gateway_fixture = GatewayTrustFixture::load()?; + let name = format!("e2e{:016x}", rand::random::()); + let mut backend = BackendPair::new(&name)?; + // The wrapper's directory is shared with the host Docker daemon in CI; + // a job-container-local temporary path cannot back the TLS bind mount. + let directory = TempDir::new_in(&gateway_fixture.directory) + .map_err(|_| "could not allocate fixture directory")?; + let context = directory.path().join("image"); + 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")?; + std::fs::write(context.join("client.py"), CLIENT) + .map_err(|_| "could not write client source")?; + let base = std::env::var("OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE") + .unwrap_or_else(|_| E2E_WORKLOAD_IMAGE.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 dockerfile = context.join("Dockerfile"); + std::fs::write( + &dockerfile, + format!("FROM {base}\nCOPY client.py /opt/stable-placeholder-client.py\nUSER 1000:1000\n"), + ) + .map_err(|_| "could not write fixture Dockerfile")?; + let supervisor_dockerfile = context.join("Dockerfile.supervisor"); + // Build the combined trust bundle in the workload image: the supervisor is + // distroless and has no shell. The final stage preserves the supervisor's + // default user and its original public trust roots. + 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/stable-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/stable-fixture-ca.crt').read_bytes())\"]\nFROM {}\nCOPY --from=trust-bundle /tmp/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt\n", + gateway_fixture.supervisor_image, + gateway_fixture.supervisor_image + )).map_err(|_| "could not write fixture supervisor Dockerfile")?; + let image = FixtureImage::new()?; + let supervisor_image = FixtureImage::new()?; + // Each backend has its own network namespace, so fixed internal ports need + // no host reservation or publication and remain independent across runs. + let port = BACKEND_PORT; + let other_port = OTHER_BACKEND_PORT; + let keys = [ + format!("e2e-{:032x}", rand::random::()), + format!("e2e-{:032x}", rand::random::()), + ]; + let profile = directory.path().join("profile.json"); + let policy = directory.path().join("policy.json"); + let profile_path = profile.to_str().ok_or("profile path was not UTF-8")?; + let policy_path = policy.to_str().ok_or("policy path was not UTF-8")?; + let curl = binaries["curl"] + .as_str() + .ok_or("curl executable was absent")?; + let mut sandbox = None; + let result = async { + // Begin container mutation inside this scope so address, certificate, + // image and enrollment failures all reach explicit bounded teardown. + image.build(&dockerfile, &context).await?; + let [host, other_host] = backend.spawn(&base, &backend_tls).await?; + let (certificate, private_key) = + generate_certificates(directory.path(), &host, &other_host).await?; + std::fs::copy(&certificate, backend_tls.join("backend.crt")) + .map_err(|_| "could not stage backend certificate")?; + let backend_tls_key = backend_tls.join("backend.key.fixture"); + std::fs::copy(&private_key, &backend_tls_key) + .map_err(|_| "could not stage backend TLS key")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + // The backends inherit the image's unprivileged user. Only their + // mounted leaf key is readable; the private CA key stays outside + // the shared mount and both image build contexts. + std::fs::set_permissions(&backend_tls_key, std::fs::Permissions::from_mode(0o444)) + .map_err(|_| "could not set backend TLS key permissions")?; + } + backend.initialize(&json!({"keys": keys, "ports": [port, other_port], + "certificate": "/fixture-tls/backend.crt", "private_key": "/fixture-tls/backend.key.fixture"})) + .await?; + std::fs::copy(directory.path().join("ca.crt"), context.join("fixture-ca.crt")) + .map_err(|_| "could not copy public fixture CA")?; + supervisor_image + .build(&supervisor_dockerfile, &context) + .await?; + gateway_fixture.apply(supervisor_image.tag()).await?; + write_profile(&profile, &name, &host, port, python, stable)?; + write_policy(&policy, &host, &other_host, port, other_port, python)?; + let configuration = json!({"host": host, "other_host": other_host, "port": port, + "other_port": other_port, "stable": stable}).to_string(); + cli( + "import synthetic provider profile", + &["provider", "profile", "import", "--file", profile_path], + None, + ) + .await?; + cli( + "create synthetic provider", + &[ + "provider", + "create", + "--name", + &name, + "--type", + &name, + "--credential", + TOKEN_ENV, + ], + Some(&keys[0]), + ) + .await?; + sandbox = Some( + SandboxGuard::create_keep_with_args( + &[ + "--from", + image.tag(), + "--provider", + &name, + "--policy", + policy_path, + "--no-auto-providers", + ], + &[ + "/usr/bin/python3", + "-u", + "/opt/stable-placeholder-client.py", + &configuration, + ], + READY, + ) + .await + .map_err(|_| "persistent sandbox client could not start")?, + ); + let running = sandbox + .as_ref() + .ok_or("sandbox was absent after creation")?; + let initial = probe(running, "initial").await?; + let pid = initial["pid"].as_u64().ok_or("client PID was absent")?; + check(&initial, pid, true, Some(0))?; + let before = backend.counts().await?; + if before["total"] != 1 || before["accepted"][0] != 1 { + return Err("initial backend request count was incorrect".to_string()); + } + for phase in [ + "wrong_host_control", + "wrong_port_control", + "wrong_path_control", + ] { + let control = probe(running, phase).await?; + check(&control, pid, false, None)?; + if control["status"] != 401 || control["backend_phase"] != 0 { + return Err(format!( + "negative endpoint {phase} was not reachable without a credential" + )); + } + } + let before = backend.counts().await?; + if before["total"] != 4 || before["rejected"] != 3 || before["accepted"] != json!([1, 0]) { + return Err("uncredentialed endpoint control assertions failed".to_string()); + } + for phase in [ + "wrong_host", + "wrong_port", + "wrong_path", + "canonical_alias", + "untrusted_ca", + ] { + check(&probe(running, phase).await?, pid, false, None)?; + if backend.counts().await? != before { + return Err(format!("denied phase {phase} reached the backend")); + } + } + probe_disallowed_binary(running, curl, &host, port).await?; + if backend.counts().await? != before { + return Err("disallowed binary reached the backend".to_string()); + } + + backend.rotate().await?; + // This is the only provider update. --wait acknowledges the exact + // saved revision before either client sends its next tool request. + acknowledged_mutation( + &["provider", "update", &name, "--credential", TOKEN_ENV], + Some(&keys[1]), + running, &name, "update", &keys, + ) + .await?; + // No workload requests occur while waiting. The first next request + // must use B with the opt-in; the ordinary profile still resolves A. + let retained = probe(running, "rotated").await?; + check(&retained, pid, stable, stable.then_some(1))?; + if !stable && (retained["status"] != 401 || retained["backend_phase"] != 1 + || retained["credential_generation"] != "A" || retained["tool_call"] != true) + { + return Err("ordinary retained reference did not send the revoked assertion A".to_string()); + } + let rotated = backend.counts().await?; + let accepted_after_rotation = if stable { json!([1, 1]) } else { json!([1, 0]) }; + let received_after_rotation = if stable { json!([1, 1]) } else { json!([2, 0]) }; + let rejected_after_rotation = if stable { 3 } else { 4 }; + if rotated["total"] != 5 || rotated["accepted"] != accepted_after_rotation + || rotated["received"] != received_after_rotation + || rotated["rejected"] != rejected_after_rotation + || rotated["missing"] != 3 || rotated["unknown"] != 0 || rotated["invalid_tool_calls"] != 0 + { + return Err("single-update backend assertions failed".to_string()); + } + + // A fresh process proves the installed provider revision can use B in + // both modes. It does not replace or modify the original client. + let fresh_output = timeout(COMMAND_TIMEOUT, running.exec(&[ + "/usr/bin/python3", "-u", "/opt/stable-placeholder-client.py", &configuration, "--once", + ])).await.map_err(|_| "fresh client timed out")? + .map_err(|_| "fresh client failed")?; + let fresh: Value = serde_json::from_str(fresh_output.trim()) + .map_err(|_| "fresh client returned invalid status")?; + let fresh_pid = fresh["pid"].as_u64().ok_or("fresh client PID was absent")?; + if fresh_pid == pid || fresh["same_reference"] != true { + return Err("fresh process control did not retain its own issued reference".to_string()); + } + check(&fresh, fresh_pid, true, Some(1))?; + let refreshed = backend.counts().await?; + let final_accepted = if stable { json!([1, 2]) } else { json!([1, 1]) }; + let final_received = if stable { json!([1, 2]) } else { json!([2, 1]) }; + if refreshed["total"] != 6 || refreshed["accepted"] != final_accepted + || refreshed["received"] != final_received || refreshed["rejected"] != rejected_after_rotation + || refreshed["missing"] != 3 || refreshed["unknown"] != 0 || refreshed["invalid_tool_calls"] != 0 + { + return Err("fresh process did not prove installed assertion B".to_string()); + } + + acknowledged_mutation( + &["sandbox", "provider", "detach", &running.name, &name], + None, + running, &name, "detach", &keys, + ) + .await?; + // Detach must revoke the reference while leaving the independently + // authorized route usable; a network outage cannot satisfy this proof. + let control = probe(running, "detached_control").await?; + check(&control, pid, false, None)?; + if control["status"] != 401 || control["backend_phase"] != 1 { + return Err("detached endpoint was not reachable without a credential".to_string()); + } + let detached = backend.counts().await?; + if detached["total"] != 7 + || detached["accepted"] != final_accepted + || detached["received"] != final_received + || detached["rejected"] != rejected_after_rotation + 1 + || detached["missing"] != 4 || detached["unknown"] != 0 || detached["invalid_tool_calls"] != 0 + || detached["phase"] != 1 + { + return Err("detached endpoint control assertions failed".to_string()); + } + check(&probe(running, "detached").await?, pid, false, None)?; + if backend.counts().await? != detached { + return Err("detached credential reached the backend".to_string()); + } + println!( + "{}", + json!({"phase": "complete", "pid": pid, "same_reference": true, + "stable_placeholder": stable, "provider_updates": 1, + "retained_assertion": if stable { "B" } else { "A" }, + "retained_request_accepted": stable, "fresh_process_assertion": "B", + "tool": "local_hello", "endpoint_denials": true, "binary_denial": true, + "canonical_alias_denial": true, "tls_verified": true, "detached": true}) + ); + Ok(()) + } + .await; + + // Resource names are unique to this run. Always attempt cleanup, including + // failures during enrollment, without exposing captured provider output. + if let Some(mut sandbox) = sandbox { + let _ = timeout(COMMAND_TIMEOUT, sandbox.cleanup()).await; + } + let _ = cli( + "delete synthetic provider", + &["provider", "delete", &name], + None, + ) + .await; + let _ = cli( + "delete synthetic profile", + &["provider", "profile", "delete", &name], + None, + ) + .await; + let backend_cleanup = backend.stop().await; + // Restore the original runtime before removing its replacement. Retain the + // derived supervisor image if restoration fails, and report that failure + // even when a lifecycle assertion already failed. + let gateway_restore = gateway_fixture.restore().await; + let supervisor_cleanup = if gateway_restore.is_ok() { + supervisor_image.remove().await + } else { + Ok(()) + }; + let image_cleanup = image.remove().await; + let failures = [ + result, + backend_cleanup, + gateway_restore, + supervisor_cleanup, + image_cleanup, + ] + .into_iter() + .filter_map(Result::err) + .collect::>(); + if failures.is_empty() { + Ok(()) + } else { + Err(failures.join("; ")) + } +} diff --git a/proto/openshell.proto b/proto/openshell.proto index 2aef9595c2..79cc8f1709 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -2355,6 +2355,12 @@ message ProviderProfileCredential { // Profile-authored values are ignored; normal policies name the originating // provider endpoint, while a global policy uses its own endpoint authorities. repeated string token_grant_owners = 11; + // Opt in to an opaque identity-bound environment placeholder for every env + // var declared by this credential. Use this only when an external credential + // updater requires one placeholder across value-only provider revisions. + // Gateway-managed refresh and token-grant credentials cannot opt in because + // they require identity-bearing handles for authorization-epoch revocation. + bool stable_placeholder = 12; } enum ProviderCredentialRefreshStrategy { @@ -2606,6 +2612,9 @@ message LintProviderProfilesRequest { message LintProviderProfilesResponse { repeated ProviderProfileDiagnostic diagnostics = 1; bool valid = 2; + // Clients enabling stable_placeholder check this before import or update, + // since older gateways silently discard unknown credential fields. + bool supports_stable_placeholder = 3; } // Delete provider response. @@ -2641,6 +2650,10 @@ message GetSandboxProviderEnvironmentRequest { // provider credentials. Gateways withhold static credential material when // this capability is absent. bool supports_static_credential_bindings = 2; + // Whether the requesting supervisor understands stable external-placeholder + // metadata. Gateways withhold credentials that require this behavior when + // the capability is absent. + bool supports_stable_placeholder_environment_keys = 3; } // One network endpoint at which a static provider credential may be resolved. @@ -2657,11 +2670,11 @@ message StaticCredentialBinding { // Supervisors use it to retain old revision placeholders only across // rotations of the same provider credential. string credential_identity = 2; - // Opaque gateway-issued handle for a refresh-managed credential identity - // epoch. When non-empty, supervisors keep the workload placeholder stable - // across access-token rotations and replace only the resolver value. The - // handle changes when the sandbox, provider, credential key, refresh - // authorization epoch, or endpoint authorization boundary changes. + // Opaque gateway-issued handle for a stable credential lifecycle. When + // non-empty, supervisors keep the workload placeholder stable across value + // updates and replace only the resolver value. The handle changes when the + // sandbox, provider, credential key, endpoint authorization, or an applicable + // refresh authorization epoch changes. string workload_credential_handle = 3; } @@ -2695,6 +2708,15 @@ message GetSandboxProviderEnvironmentResponse { ProviderReadinessReason readiness_reason = 9; // Complete desired set of non-secret managed files, keyed by absolute path. map files = 10; + // Credential environment variables whose opaque resolver handle remains + // stable across value-only provider revisions. These keys are still secrets; + // this metadata only selects the stable external-placeholder behavior. + repeated string stable_placeholder_environment_keys = 11; + // Supporting gateways always acknowledge the stable-placeholder delivery + // contract, including responses without opted-in credentials. Supervisors + // require acknowledgment for declared or previously activated external + // stable credentials; ordinary legacy delivery does not require it. + bool supports_stable_placeholder_environment_keys = 12; } message ExchangeProviderSubjectTokenRequest { diff --git a/sdk/go/openshell/v1/internal/converter/coverage_test.go b/sdk/go/openshell/v1/internal/converter/coverage_test.go index be563af9cd..1c58c400b6 100644 --- a/sdk/go/openshell/v1/internal/converter/coverage_test.go +++ b/sdk/go/openshell/v1/internal/converter/coverage_test.go @@ -329,16 +329,17 @@ func TestConverterCoversAllProtoFields_ProviderProfile(t *testing.T) { func TestConverterCoversAllProtoFields_ProviderProfileCredential(t *testing.T) { handled := fieldSet{ - "name": true, - "description": true, - "env_vars": true, - "required": true, - "auth_style": true, - "header_name": true, - "query_param": true, - "refresh": true, - "path_template": true, - "token_grant": true, + "name": true, + "description": true, + "env_vars": true, + "required": true, + "auth_style": true, + "header_name": true, + "query_param": true, + "refresh": true, + "path_template": true, + "token_grant": true, + "stable_placeholder": true, } // Resolved grant authorities belong to gateway-to-supervisor delivery, diff --git a/sdk/go/openshell/v1/internal/converter/profile.go b/sdk/go/openshell/v1/internal/converter/profile.go index 9ef58e8057..b3a5ee88c8 100644 --- a/sdk/go/openshell/v1/internal/converter/profile.go +++ b/sdk/go/openshell/v1/internal/converter/profile.go @@ -138,17 +138,18 @@ func ProfileCredentialFromProto(c *pb.ProviderProfileCredential) *types.ProfileC return nil } return &types.ProfileCredential{ - Name: c.GetName(), - Description: c.GetDescription(), - EnvVars: CopyStringSlice(c.GetEnvVars()), - Required: c.GetRequired(), - Secret: c.GetRefresh() != nil, - Refresh: profileCredentialRefreshFromProto(c.GetRefresh()), - AuthStyle: c.GetAuthStyle(), - HeaderName: c.GetHeaderName(), - QueryParam: c.GetQueryParam(), - PathTemplate: c.GetPathTemplate(), - TokenGrant: tokenGrantFromProto(c.GetTokenGrant()), + Name: c.GetName(), + Description: c.GetDescription(), + EnvVars: CopyStringSlice(c.GetEnvVars()), + Required: c.GetRequired(), + StablePlaceholder: c.GetStablePlaceholder(), + Secret: c.GetRefresh() != nil, + Refresh: profileCredentialRefreshFromProto(c.GetRefresh()), + AuthStyle: c.GetAuthStyle(), + HeaderName: c.GetHeaderName(), + QueryParam: c.GetQueryParam(), + PathTemplate: c.GetPathTemplate(), + TokenGrant: tokenGrantFromProto(c.GetTokenGrant()), } } @@ -158,16 +159,17 @@ func ProfileCredentialToProto(c *types.ProfileCredential) *pb.ProviderProfileCre return nil } return &pb.ProviderProfileCredential{ - Name: c.Name, - Description: c.Description, - EnvVars: CopyStringSlice(c.EnvVars), - Required: c.Required, - AuthStyle: c.AuthStyle, - HeaderName: c.HeaderName, - QueryParam: c.QueryParam, - PathTemplate: c.PathTemplate, - Refresh: profileCredentialRefreshToProto(c.Refresh), - TokenGrant: tokenGrantToProto(c.TokenGrant), + Name: c.Name, + Description: c.Description, + EnvVars: CopyStringSlice(c.EnvVars), + Required: c.Required, + StablePlaceholder: c.StablePlaceholder, + AuthStyle: c.AuthStyle, + HeaderName: c.HeaderName, + QueryParam: c.QueryParam, + PathTemplate: c.PathTemplate, + Refresh: profileCredentialRefreshToProto(c.Refresh), + TokenGrant: tokenGrantToProto(c.TokenGrant), } } diff --git a/sdk/go/openshell/v1/internal/converter/profile_test.go b/sdk/go/openshell/v1/internal/converter/profile_test.go index e1aaf0af75..28f58533ba 100644 --- a/sdk/go/openshell/v1/internal/converter/profile_test.go +++ b/sdk/go/openshell/v1/internal/converter/profile_test.go @@ -139,14 +139,15 @@ func TestNetworkBinaryToProto_Nil(t *testing.T) { func TestProfileCredentialFromProto(t *testing.T) { proto := &pb.ProviderProfileCredential{ - Name: "API_KEY", - Description: "API key for auth", - EnvVars: []string{"ANTHROPIC_API_KEY", "API_KEY"}, - Required: true, - AuthStyle: "header", - HeaderName: "X-API-Key", - QueryParam: "api_key", - PathTemplate: "/v1/{credential}/chat", + Name: "API_KEY", + Description: "API key for auth", + EnvVars: []string{"ANTHROPIC_API_KEY", "API_KEY"}, + Required: true, + StablePlaceholder: true, + AuthStyle: "header", + HeaderName: "X-API-Key", + QueryParam: "api_key", + PathTemplate: "/v1/{credential}/chat", Refresh: &pb.ProviderCredentialRefresh{ Strategy: pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN, }, @@ -177,6 +178,7 @@ func TestProfileCredentialFromProto(t *testing.T) { assert.Equal(t, "API key for auth", cred.Description) assert.Equal(t, []string{"ANTHROPIC_API_KEY", "API_KEY"}, cred.EnvVars) assert.True(t, cred.Required) + assert.True(t, cred.StablePlaceholder) assert.True(t, cred.Secret, "credential with refresh config is secret") assert.Equal(t, "header", cred.AuthStyle) assert.Equal(t, "X-API-Key", cred.HeaderName) @@ -319,11 +321,12 @@ func TestProfileCredentialFromProto_Nil(t *testing.T) { func TestProfileCredentialToProto(t *testing.T) { cred := &v1.ProfileCredential{ - Name: "API_KEY", - Description: "API key", - EnvVars: []string{"ANTHROPIC_API_KEY"}, - Required: true, - Secret: true, + Name: "API_KEY", + Description: "API key", + EnvVars: []string{"ANTHROPIC_API_KEY"}, + Required: true, + StablePlaceholder: true, + Secret: true, Refresh: &v1.ProfileCredentialRefresh{ Strategy: v1.RefreshStrategyOAuth2RefreshToken, TokenURL: "https://auth.example.com/token", @@ -364,6 +367,7 @@ func TestProfileCredentialToProto(t *testing.T) { assert.Equal(t, "API key", proto.Description) assert.Equal(t, []string{"ANTHROPIC_API_KEY"}, proto.EnvVars) assert.True(t, proto.Required) + assert.True(t, proto.StablePlaceholder) assert.Equal(t, "header", proto.AuthStyle) assert.Equal(t, "X-API-Key", proto.HeaderName) assert.Equal(t, "api_key", proto.QueryParam) diff --git a/sdk/go/openshell/v1/profile_client.go b/sdk/go/openshell/v1/profile_client.go index a82a90052d..5c3acac9bf 100644 --- a/sdk/go/openshell/v1/profile_client.go +++ b/sdk/go/openshell/v1/profile_client.go @@ -5,6 +5,7 @@ package v1 import ( "context" + "fmt" "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" @@ -66,6 +67,11 @@ func (p *profileClient) Import(ctx context.Context, workspace string, items []Pr for i := range items { pbItems[i] = converter.ProfileImportItemToProto(&items[i]) } + if profilesRequireStablePlaceholders(pbItems) { + if _, err := p.lint(ctx, workspace, pbItems); err != nil { + return nil, err + } + } resp, err := p.client.ImportProviderProfiles(ctx, &pb.ImportProviderProfilesRequest{ Profiles: pbItems, @@ -95,9 +101,15 @@ func (p *profileClient) Import(ctx context.Context, workspace string, items []Pr } func (p *profileClient) Update(ctx context.Context, workspace, id string, expectedResourceVersion uint64, item ProfileImportItem) (*UpdateResult, error) { + pbItem := converter.ProfileImportItemToProto(&item) + if items := []*pb.ProviderProfileImportItem{pbItem}; profilesRequireStablePlaceholders(items) { + if _, err := p.lint(ctx, workspace, items); err != nil { + return nil, err + } + } resp, err := p.client.UpdateProviderProfiles(ctx, &pb.UpdateProviderProfilesRequest{ Id: id, - Profile: converter.ProfileImportItemToProto(&item), + Profile: pbItem, ExpectedResourceVersion: expectedResourceVersion, WorkspaceScope: profileWorkspaceScope(workspace), }) @@ -125,12 +137,9 @@ func (p *profileClient) Lint(ctx context.Context, workspace string, items []Prof pbItems[i] = converter.ProfileImportItemToProto(&items[i]) } - resp, err := p.client.LintProviderProfiles(ctx, &pb.LintProviderProfilesRequest{ - Profiles: pbItems, - WorkspaceScope: profileWorkspaceScope(workspace), - }) + resp, err := p.lint(ctx, workspace, pbItems) if err != nil { - return nil, converter.FromGRPCError(err) + return nil, err } result := &LintResult{ @@ -146,6 +155,33 @@ func (p *profileClient) Lint(ctx context.Context, workspace string, items []Prof return result, nil } +func profilesRequireStablePlaceholders(items []*pb.ProviderProfileImportItem) bool { + for _, item := range items { + for _, credential := range item.GetProfile().GetCredentials() { + if credential.GetStablePlaceholder() { + return true + } + } + } + return false +} + +// Lint acknowledges feature support before an opt-in is persisted. Legacy +// gateways can otherwise silently discard unknown credential fields. +func (p *profileClient) lint(ctx context.Context, workspace string, items []*pb.ProviderProfileImportItem) (*pb.LintProviderProfilesResponse, error) { + resp, err := p.client.LintProviderProfiles(ctx, &pb.LintProviderProfilesRequest{ + Profiles: items, + WorkspaceScope: profileWorkspaceScope(workspace), + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + if profilesRequireStablePlaceholders(items) && !resp.GetSupportsStablePlaceholder() { + return nil, fmt.Errorf("gateway does not support stable_placeholder; upgrade the gateway before enabling it") + } + return resp, nil +} + func (p *profileClient) Delete(ctx context.Context, workspace, id string, opts ...DeleteOptions) (*DeletionResult, error) { resp, err := p.client.DeleteProviderProfile(ctx, &pb.DeleteProviderProfileRequest{ AllowMissing: allowMissing(opts), diff --git a/sdk/go/openshell/v1/profile_client_test.go b/sdk/go/openshell/v1/profile_client_test.go index 32708d58c0..f06ebe28a2 100644 --- a/sdk/go/openshell/v1/profile_client_test.go +++ b/sdk/go/openshell/v1/profile_client_test.go @@ -5,6 +5,7 @@ package v1 import ( "context" + "fmt" "net" "sync" "testing" @@ -34,7 +35,8 @@ type mockProfileServer struct { lintErr error deleteErr error - lastListReq *pb.ListProviderProfilesRequest + lastListReq *pb.ListProviderProfilesRequest + supportsStablePlaceholder bool } func newMockProfileServer() *mockProfileServer { @@ -145,8 +147,9 @@ func (s *mockProfileServer) LintProviderProfiles(_ context.Context, req *pb.Lint } } return &pb.LintProviderProfilesResponse{ - Diagnostics: diagnostics, - Valid: valid, + Diagnostics: diagnostics, + Valid: valid, + SupportsStablePlaceholder: s.supportsStablePlaceholder, }, nil } @@ -321,6 +324,47 @@ func TestProfileGet_Error(t *testing.T) { // --- Import tests --- +func TestProfileStablePlaceholderCompatibility(t *testing.T) { + for _, supports := range []bool{false, true} { + for _, enabled := range []bool{false, true} { + t.Run(fmt.Sprintf("gateway_supports=%t/enabled=%t", supports, enabled), func(t *testing.T) { + mock := newMockProfileServer() + mock.supportsStablePlaceholder = supports + if !enabled { + // Ordinary writes must not acquire a new dependency on lint. + mock.lintErr = status.Error(codes.Unimplemented, "legacy lint unavailable") + } + seedProfile(mock, "existing", "Existing", pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE) + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + item := ProfileImportItem{Profile: ProviderProfile{ + ID: "new", + Credentials: []ProfileCredential{{Name: "token", StablePlaceholder: enabled}}, + }} + imported, importErr := client.Import(context.Background(), "default", []ProfileImportItem{item}) + item.Profile.ID = "existing" + updated, updateErr := client.Update(context.Background(), "default", "existing", 1, item) + if enabled && !supports { + require.ErrorContains(t, importErr, "does not support stable_placeholder") + require.ErrorContains(t, updateErr, "does not support stable_placeholder") + mock.mu.Lock() + defer mock.mu.Unlock() + assert.NotContains(t, mock.profiles, "new") + assert.Equal(t, uint64(1), mock.profiles["existing"].GetResourceVersion()) + assert.False(t, mock.profiles["existing"].GetCredentials()[0].GetStablePlaceholder()) + } else { + require.NoError(t, importErr) + require.NoError(t, updateErr) + assert.True(t, imported.Imported) + assert.True(t, updated.Updated) + assert.Equal(t, enabled, imported.Profiles[0].Credentials[0].StablePlaceholder) + assert.Equal(t, enabled, updated.Profile.Credentials[0].StablePlaceholder) + } + }) + } + } +} + func TestProfileImport(t *testing.T) { mock := newMockProfileServer() client, cleanup := setupProfileTest(t, mock) diff --git a/sdk/go/openshell/v1/types/profile.go b/sdk/go/openshell/v1/types/profile.go index fdffd2bfe6..1589eb2ab9 100644 --- a/sdk/go/openshell/v1/types/profile.go +++ b/sdk/go/openshell/v1/types/profile.go @@ -47,17 +47,21 @@ type ProfileFile struct { // ProfileCredential defines a single credential required by a provider profile. type ProfileCredential struct { - Name string - Description string - EnvVars []string - Required bool - Secret bool - Refresh *ProfileCredentialRefresh - AuthStyle string - HeaderName string - QueryParam string - PathTemplate string - TokenGrant *CredentialTokenGrant + Name string + Description string + EnvVars []string + Required bool + // StablePlaceholder uses an opaque identity-bound resolver handle across + // value-only external provider updates. It does not change secret + // classification or bypass endpoint authorization. + StablePlaceholder bool + Secret bool + Refresh *ProfileCredentialRefresh + AuthStyle string + HeaderName string + QueryParam string + PathTemplate string + TokenGrant *CredentialTokenGrant } // ProfileCredentialRefresh declares how a profile credential is refreshed. diff --git a/sdk/go/proto/openshellv1/openshell.pb.go b/sdk/go/proto/openshellv1/openshell.pb.go index a8cf975097..d9359c253f 100644 --- a/sdk/go/proto/openshellv1/openshell.pb.go +++ b/sdk/go/proto/openshellv1/openshell.pb.go @@ -9299,8 +9299,14 @@ type ProviderProfileCredential struct { // Profile-authored values are ignored; normal policies name the originating // provider endpoint, while a global policy uses its own endpoint authorities. TokenGrantOwners []string `protobuf:"bytes,11,rep,name=token_grant_owners,json=tokenGrantOwners,proto3" json:"token_grant_owners,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Opt in to an opaque identity-bound environment placeholder for every env + // var declared by this credential. Use this only when an external credential + // updater requires one placeholder across value-only provider revisions. + // Gateway-managed refresh and token-grant credentials cannot opt in because + // they require identity-bearing handles for authorization-epoch revocation. + StablePlaceholder bool `protobuf:"varint,12,opt,name=stable_placeholder,json=stablePlaceholder,proto3" json:"stable_placeholder,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ProviderProfileCredential) Reset() { @@ -9410,6 +9416,13 @@ func (x *ProviderProfileCredential) GetTokenGrantOwners() []string { return nil } +func (x *ProviderProfileCredential) GetStablePlaceholder() bool { + if x != nil { + return x.StablePlaceholder + } + return false +} + type ProviderCredentialRefreshMaterial struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -10962,11 +10975,14 @@ func (x *LintProviderProfilesRequest) GetProfiles() []*ProviderProfileImportItem // Lint provider profiles response. type LintProviderProfilesResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Diagnostics []*ProviderProfileDiagnostic `protobuf:"bytes,1,rep,name=diagnostics,proto3" json:"diagnostics,omitempty"` - Valid bool `protobuf:"varint,2,opt,name=valid,proto3" json:"valid,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Diagnostics []*ProviderProfileDiagnostic `protobuf:"bytes,1,rep,name=diagnostics,proto3" json:"diagnostics,omitempty"` + Valid bool `protobuf:"varint,2,opt,name=valid,proto3" json:"valid,omitempty"` + // Clients enabling stable_placeholder check this before import or update, + // since older gateways silently discard unknown credential fields. + SupportsStablePlaceholder bool `protobuf:"varint,3,opt,name=supports_stable_placeholder,json=supportsStablePlaceholder,proto3" json:"supports_stable_placeholder,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *LintProviderProfilesResponse) Reset() { @@ -11013,6 +11029,13 @@ func (x *LintProviderProfilesResponse) GetValid() bool { return false } +func (x *LintProviderProfilesResponse) GetSupportsStablePlaceholder() bool { + if x != nil { + return x.SupportsStablePlaceholder + } + return false +} + // Delete provider response. type DeleteProviderResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -11184,8 +11207,12 @@ type GetSandboxProviderEnvironmentRequest struct { // provider credentials. Gateways withhold static credential material when // this capability is absent. SupportsStaticCredentialBindings bool `protobuf:"varint,2,opt,name=supports_static_credential_bindings,json=supportsStaticCredentialBindings,proto3" json:"supports_static_credential_bindings,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Whether the requesting supervisor understands stable external-placeholder + // metadata. Gateways withhold credentials that require this behavior when + // the capability is absent. + SupportsStablePlaceholderEnvironmentKeys bool `protobuf:"varint,3,opt,name=supports_stable_placeholder_environment_keys,json=supportsStablePlaceholderEnvironmentKeys,proto3" json:"supports_stable_placeholder_environment_keys,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetSandboxProviderEnvironmentRequest) Reset() { @@ -11232,6 +11259,13 @@ func (x *GetSandboxProviderEnvironmentRequest) GetSupportsStaticCredentialBindin return false } +func (x *GetSandboxProviderEnvironmentRequest) GetSupportsStablePlaceholderEnvironmentKeys() bool { + if x != nil { + return x.SupportsStablePlaceholderEnvironmentKeys + } + return false +} + // One network endpoint at which a static provider credential may be resolved. type StaticCredentialEndpointBinding struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -11301,11 +11335,11 @@ type StaticCredentialBinding struct { // Supervisors use it to retain old revision placeholders only across // rotations of the same provider credential. CredentialIdentity string `protobuf:"bytes,2,opt,name=credential_identity,json=credentialIdentity,proto3" json:"credential_identity,omitempty"` - // Opaque gateway-issued handle for a refresh-managed credential identity - // epoch. When non-empty, supervisors keep the workload placeholder stable - // across access-token rotations and replace only the resolver value. The - // handle changes when the sandbox, provider, credential key, refresh - // authorization epoch, or endpoint authorization boundary changes. + // Opaque gateway-issued handle for a stable credential lifecycle. When + // non-empty, supervisors keep the workload placeholder stable across value + // updates and replace only the resolver value. The handle changes when the + // sandbox, provider, credential key, endpoint authorization, or an applicable + // refresh authorization epoch changes. WorkloadCredentialHandle string `protobuf:"bytes,3,opt,name=workload_credential_handle,json=workloadCredentialHandle,proto3" json:"workload_credential_handle,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -11390,9 +11424,18 @@ type GetSandboxProviderEnvironmentResponse struct { // Nonzero when material was withheld; installing an empty map is not readiness. ReadinessReason ProviderReadinessReason `protobuf:"varint,9,opt,name=readiness_reason,json=readinessReason,proto3,enum=openshell.v1.ProviderReadinessReason" json:"readiness_reason,omitempty"` // Complete desired set of non-secret managed files, keyed by absolute path. - Files map[string]string `protobuf:"bytes,10,rep,name=files,proto3" json:"files,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Files map[string]string `protobuf:"bytes,10,rep,name=files,proto3" json:"files,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Credential environment variables whose opaque resolver handle remains + // stable across value-only provider revisions. These keys are still secrets; + // this metadata only selects the stable external-placeholder behavior. + StablePlaceholderEnvironmentKeys []string `protobuf:"bytes,11,rep,name=stable_placeholder_environment_keys,json=stablePlaceholderEnvironmentKeys,proto3" json:"stable_placeholder_environment_keys,omitempty"` + // Supporting gateways always acknowledge the stable-placeholder delivery + // contract, including responses without opted-in credentials. Supervisors + // require acknowledgment for declared or previously activated external + // stable credentials; ordinary legacy delivery does not require it. + SupportsStablePlaceholderEnvironmentKeys bool `protobuf:"varint,12,opt,name=supports_stable_placeholder_environment_keys,json=supportsStablePlaceholderEnvironmentKeys,proto3" json:"supports_stable_placeholder_environment_keys,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetSandboxProviderEnvironmentResponse) Reset() { @@ -11495,6 +11538,20 @@ func (x *GetSandboxProviderEnvironmentResponse) GetFiles() map[string]string { return nil } +func (x *GetSandboxProviderEnvironmentResponse) GetStablePlaceholderEnvironmentKeys() []string { + if x != nil { + return x.StablePlaceholderEnvironmentKeys + } + return nil +} + +func (x *GetSandboxProviderEnvironmentResponse) GetSupportsStablePlaceholderEnvironmentKeys() bool { + if x != nil { + return x.SupportsStablePlaceholderEnvironmentKeys + } + return false +} + type ExchangeProviderSubjectTokenRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The sandbox ID. Must match the authenticated sandbox principal. @@ -18813,7 +18870,7 @@ const file_openshell_proto_rawDesc = "" + "grant_type\x18\b \x01(\x0e2..openshell.v1.ProviderCredentialTokenGrantTypeR\tgrantType\x12[\n" + "\rsubject_token\x18\t \x01(\v26.openshell.v1.ProviderCredentialTokenGrantSubjectTokenR\fsubjectToken\x120\n" + "\x14requested_token_type\x18\n" + - " \x01(\tR\x12requestedTokenTypeJ\x04\b\x04\x10\x05R\x11cache_ttl_seconds\"\xcc\x03\n" + + " \x01(\tR\x12requestedTokenTypeJ\x04\b\x04\x10\x05R\x11cache_ttl_seconds\"\xfb\x03\n" + "\x19ProviderProfileCredential\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x19\n" + @@ -18830,7 +18887,8 @@ const file_openshell_proto_rawDesc = "" + "\vtoken_grant\x18\n" + " \x01(\v2*.openshell.v1.ProviderCredentialTokenGrantR\n" + "tokenGrant\x12,\n" + - "\x12token_grant_owners\x18\v \x03(\tR\x10tokenGrantOwners\"\x8d\x01\n" + + "\x12token_grant_owners\x18\v \x03(\tR\x10tokenGrantOwners\x12-\n" + + "\x12stable_placeholder\x18\f \x01(\bR\x11stablePlaceholder\"\x8d\x01\n" + "!ProviderCredentialRefreshMaterial\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x1a\n" + @@ -18956,10 +19014,11 @@ const file_openshell_proto_rawDesc = "" + "\aupdated\x18\x03 \x01(\bR\aupdated\"\xb6\x01\n" + "\x1bLintProviderProfilesRequest\x12R\n" + "\x0fworkspace_scope\x18\x02 \x01(\v2).openshell.datamodel.v1.WorkspaceSelectorR\x0eworkspaceScope\x12C\n" + - "\bprofiles\x18\x01 \x03(\v2'.openshell.v1.ProviderProfileImportItemR\bprofiles\"\x7f\n" + + "\bprofiles\x18\x01 \x03(\v2'.openshell.v1.ProviderProfileImportItemR\bprofiles\"\xbf\x01\n" + "\x1cLintProviderProfilesResponse\x12I\n" + "\vdiagnostics\x18\x01 \x03(\v2'.openshell.v1.ProviderProfileDiagnosticR\vdiagnostics\x12\x14\n" + - "\x05valid\x18\x02 \x01(\bR\x05valid\"`\n" + + "\x05valid\x18\x02 \x01(\bR\x05valid\x12>\n" + + "\x1bsupports_stable_placeholder\x18\x03 \x01(\bR\x19supportsStablePlaceholder\"`\n" + "\x16DeleteProviderResponse\x127\n" + "\aoutcome\x18\x02 \x01(\x0e2\x1d.openshell.v1.DeletionOutcomeR\aoutcomeJ\x04\b\x01\x10\x02R\adeleted\"\xc6\x01\n" + "\x1cDeleteProviderProfileRequest\x12R\n" + @@ -18969,11 +19028,12 @@ const file_openshell_proto_rawDesc = "" + "\n" + "request_id\x18\x04 \x01(\tR\trequestId\"g\n" + "\x1dDeleteProviderProfileResponse\x127\n" + - "\aoutcome\x18\x02 \x01(\x0e2\x1d.openshell.v1.DeletionOutcomeR\aoutcomeJ\x04\b\x01\x10\x02R\adeleted\"\x94\x01\n" + + "\aoutcome\x18\x02 \x01(\x0e2\x1d.openshell.v1.DeletionOutcomeR\aoutcomeJ\x04\b\x01\x10\x02R\adeleted\"\xf4\x01\n" + "$GetSandboxProviderEnvironmentRequest\x12\x1d\n" + "\n" + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12M\n" + - "#supports_static_credential_bindings\x18\x02 \x01(\bR supportsStaticCredentialBindings\"]\n" + + "#supports_static_credential_bindings\x18\x02 \x01(\bR supportsStaticCredentialBindings\x12^\n" + + ",supports_stable_placeholder_environment_keys\x18\x03 \x01(\bR(supportsStablePlaceholderEnvironmentKeys\"]\n" + "\x1fStaticCredentialEndpointBinding\x12\x12\n" + "\x04host\x18\x01 \x01(\tR\x04host\x12\x12\n" + "\x04port\x18\x02 \x01(\rR\x04port\x12\x12\n" + @@ -18981,7 +19041,7 @@ const file_openshell_proto_rawDesc = "" + "\x17StaticCredentialBinding\x12K\n" + "\tendpoints\x18\x01 \x03(\v2-.openshell.v1.StaticCredentialEndpointBindingR\tendpoints\x12/\n" + "\x13credential_identity\x18\x02 \x01(\tR\x12credentialIdentity\x12<\n" + - "\x1aworkload_credential_handle\x18\x03 \x01(\tR\x18workloadCredentialHandle\"\x9a\v\n" + + "\x1aworkload_credential_handle\x18\x03 \x01(\tR\x18workloadCredentialHandle\"\xc9\f\n" + "%GetSandboxProviderEnvironmentResponse\x12l\n" + "\venvironment\x18\x01 \x03(\v2D.openshell.v1.GetSandboxProviderEnvironmentResponse.EnvironmentEntryB\x04\x88\xb5\x18\x01R\venvironment\x122\n" + "\x15provider_env_revision\x18\x02 \x01(\x04R\x13providerEnvRevision\x12\x92\x01\n" + @@ -18994,7 +19054,9 @@ const file_openshell_proto_rawDesc = "" + "policyHash\x12P\n" + "\x10readiness_reason\x18\t \x01(\x0e2%.openshell.v1.ProviderReadinessReasonR\x0freadinessReason\x12T\n" + "\x05files\x18\n" + - " \x03(\v2>.openshell.v1.GetSandboxProviderEnvironmentResponse.FilesEntryR\x05files\x1a>\n" + + " \x03(\v2>.openshell.v1.GetSandboxProviderEnvironmentResponse.FilesEntryR\x05files\x12M\n" + + "#stable_placeholder_environment_keys\x18\v \x03(\tR stablePlaceholderEnvironmentKeys\x12^\n" + + ",supports_stable_placeholder_environment_keys\x18\f \x01(\bR(supportsStablePlaceholderEnvironmentKeys\x1a>\n" + "\x10EnvironmentEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1ah\n" + diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index 472d7fadbb..7bfb049f49 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -196,6 +196,8 @@ openshell provider delete my-github `provider update` does not take `--type`. It updates credentials, config, or credential expiry on the existing provider. +For an external updater whose workload must retain its original environment reference, use a credential profile with `stable_placeholder: true` and a gateway and supervisor that support that mode. The CLI checks gateway support before importing or updating the opt-in. The default remains `false`, preserving existing revision-scoped behavior and ordinary legacy gateway operation. Preserve the supervisor-issued value; never manufacture a placeholder. After the supervisor installs a value-only update, the retained reference resolves the current credential only at its effective host, port, and path binding. Replacement, expiry, changed binding, or an activated detach prevents old authority from resolving. Remove the opt-in and activate that change before downgrading the gateway. See the provider profiles documentation for the lifecycle contract and its separation from gateway-managed refresh and `token_grant`. + ### Configure credential refresh Use refresh commands only when the provider profile and gateway support refreshable credentials: From e0eabae886bf618b3bca18f36e3a2326e8923a1a Mon Sep 17 00:00:00 2001 From: "John T. Myers" <9696606+johntmyers@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:25:58 +0000 Subject: [PATCH 09/32] fix(gator): surface PR-owned security regressions in reviews (#4338) Signed-off-by: John Myers Co-authored-by: John Myers --- .agents/skills/launch-openshell-gator/SKILL.md | 5 +++++ .claude/agents/principal-engineer-reviewer.md | 5 +++++ scripts/agents/gator/agent.yaml | 2 +- .../gator/bin/review_feedback_ledger_test.sh | 10 +++++++++- scripts/agents/gator/prompts/gator.md | 6 ++++++ scripts/agents/gator/skills/gator-gate/SKILL.md | 17 ++++++++++++++++- 6 files changed, 42 insertions(+), 3 deletions(-) diff --git a/.agents/skills/launch-openshell-gator/SKILL.md b/.agents/skills/launch-openshell-gator/SKILL.md index 93addd1119..2a516754ee 100644 --- a/.agents/skills/launch-openshell-gator/SKILL.md +++ b/.agents/skills/launch-openshell-gator/SKILL.md @@ -286,6 +286,11 @@ The launcher streams image-build and provisioning output to the terminal. Import size-limited full-diff endpoint. An unavailable patch ID alone does not block the ledger; it disables rebase-equivalence shortcuts. Reviewers should inspect oversized changes file-by-file locally. +- Security regressions introduced or newly exposed by an unmerged PR belong + in its normal review findings. Private follow-up under `SECURITY.md` is for + pre-existing vulnerabilities independent of the PR; security classification + alone is not a private-triage or test-dispatch gate. Gator must not initiate + external security reporting without explicit operator authorization. ### Inspect Active Sandboxes diff --git a/.claude/agents/principal-engineer-reviewer.md b/.claude/agents/principal-engineer-reviewer.md index ee31612144..f944fbf7af 100644 --- a/.claude/agents/principal-engineer-reviewer.md +++ b/.claude/agents/principal-engineer-reviewer.md @@ -99,6 +99,11 @@ When reviewing code or diffs: - Treat pre-existing security issues as private security follow-up, not public blockers on the current pull request. Treat other pre-existing defects as non-blocking follow-up work. +- Surface security regressions introduced or newly exposed by an unmerged PR + in the normal PR review evidence contract. A new reachable path, trusted sink, + or security contract is PR-owned even when an underlying unsafe primitive + already existed. Security classification alone is not a private-triage gate. + Do not contact external reporting channels without operator authorization. - Keep docs, skill drift, diagnostic wording, and test-strength feedback advisory unless the published contract is materially false, the diagnostic creates an operational or safety failure, or missing coverage leaves a diff --git a/scripts/agents/gator/agent.yaml b/scripts/agents/gator/agent.yaml index 5aacfb136e..b1e7cc1e8a 100644 --- a/scripts/agents/gator/agent.yaml +++ b/scripts/agents/gator/agent.yaml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 id: gator -payload_version: 11 +payload_version: 12 display_name: Gator Gate Agent description: Validate and monitor OpenShell GitHub issues and pull requests through the gator state machine. diff --git a/scripts/agents/gator/bin/review_feedback_ledger_test.sh b/scripts/agents/gator/bin/review_feedback_ledger_test.sh index e671ef4562..c97f5b941c 100755 --- a/scripts/agents/gator/bin/review_feedback_ledger_test.sh +++ b/scripts/agents/gator/bin/review_feedback_ledger_test.sh @@ -406,7 +406,7 @@ rg -q 'COPY bin/validate-review-findings /usr/local/bin/validate-review-findings "$GATOR_DIR/Dockerfile" ruby -ryaml -e ' manifest = YAML.load_file(ARGV.fetch(0)) - abort unless manifest.fetch("payload_version") == 11 + abort unless manifest.fetch("payload_version") == 12 resource = manifest.fetch("resources").find { |entry| entry.fetch("id") == "gator-review-findings-schema" } @@ -421,6 +421,14 @@ rg -q 'review-feedback-ledger NVIDIA OpenShell ' \ rg -q 'Every prior Gator finding is a durable review disposition' \ "$GATOR_DIR/skills/gator-gate/SKILL.md" rg -q 'review feedback ledger' "$GATOR_DIR/prompts/gator.md" +# Keep security routing consistent across the gate, top-level prompt, and +# independent reviewer; PR-owned regressions must remain actionable reviews. +rg -Fq 'PR in that PR' "$GATOR_DIR/skills/gator-gate/SKILL.md" +rg -Fq 'private_security_review_required' "$GATOR_DIR/skills/gator-gate/SKILL.md" +rg -Fq 'Do not initiate external disclosure' "$GATOR_DIR/skills/gator-gate/SKILL.md" +rg -Fq 'not a generic private-security blocker' "$GATOR_DIR/prompts/gator.md" +rg -Fq 'Security classification alone is not a private-triage gate' \ + "$GATOR_DIR/../../../.claude/agents/principal-engineer-reviewer.md" rg -q '### Pragmatic review calibration' \ "$GATOR_DIR/skills/gator-gate/SKILL.md" rg -q 'A new commit permits a delta review' \ diff --git a/scripts/agents/gator/prompts/gator.md b/scripts/agents/gator/prompts/gator.md index 27cba2a0c8..d4c707b3db 100644 --- a/scripts/agents/gator/prompts/gator.md +++ b/scripts/agents/gator/prompts/gator.md @@ -34,6 +34,12 @@ Important sandbox constraints: - Use `gator:approval-needed` only when gator is complete but maintainer approval is still missing. Once maintainer approval is present and required checks remain green with no unresolved feedback, move to `gator:merge-ready` for the final merge or close decision. - Before running the `principal-engineer-reviewer` sub-agent or posting a review disposition, check existing gator comments and PR reviews for the current `headRefOid`. Do not run a reviewer or post another marked review/status disposition for a head SHA that already has one unless a maintainer explicitly requests a same-SHA public response, the PR is merged/closed and needs terminal cleanup, or the earlier attempt failed before posting. A prior marked comment that only says the reviewer sub-agent failed before producing output is a legacy infrastructure-failure report, not a valid review disposition; ignore it and retry the reviewer. A prior marked `## Blocked` comment whose only blocker was that the PR was draft is also not a valid code-review disposition after the PR becomes ready for review; ignore it for review suppression and run the reviewer once. Same-SHA CI changes, human replies, label changes, and reviewer comments must not create public status comments; record them only in the supervised result sentinel. A state-specific TTL nudge is the exception: after 48 business hours and no more often than once per 48 business hours for the same state and responsible actor, post the matching `## Author Follow-Up Nudge`, `## Maintainer Review Nudge`, `## Merge Decision Nudge`, or `## Blocker Follow-Up Nudge` template even when the head SHA is unchanged. A nudge must name the pending action, does not authorize a re-review, and does not consume or replace the one review disposition for that SHA. - When the gator skill requires the `principal-engineer-reviewer` sub-agent and the current effective patch has not already been reviewed by gator, first build the required review feedback ledger with `review-feedback-ledger`, then run a bounded independent review with `{{REVIEWER_COMMAND}}`. Treat the ledger's review mode, tree identity, patch identity, previous reviewed SHA, review budget, and telemetry as authoritative. Use the full PR diff for an initial review; for a follow-up, inspect unresolved feedback plus the author-only delta and do not mine unchanged or upstream-only code for new findings. Carry open findings without duplicating them, and preserve resolved or waived dispositions unless the new diff materially invalidates them. +- Surface security regressions introduced or newly exposed by an unmerged PR + as normal PR review findings, not a generic private-security blocker. Reserve + private follow-up under `SECURITY.md` for pre-existing vulnerabilities + independent of the PR. Do not initiate external reporting without explicit + operator authorization; security classification alone does not suspend + operator-authorized test dispatch or waive normal review blockers. - Require reviewer output to follow the JSON evidence contract in `/etc/openshell/agent-payload/skills/gator-gate/references/review-findings-schema.md`. Normalize it with `validate-review-findings`; only entries with diff --git a/scripts/agents/gator/skills/gator-gate/SKILL.md b/scripts/agents/gator/skills/gator-gate/SKILL.md index 3509984bed..efef39291c 100644 --- a/scripts/agents/gator/skills/gator-gate/SKILL.md +++ b/scripts/agents/gator/skills/gator-gate/SKILL.md @@ -40,7 +40,14 @@ If the `principal-engineer-reviewer` sub-agent fails before producing usable rev - You may push changes only when explicitly instructed by a GitHub comment from a maintainer or by a direct operator prompt. - Do not post `/ok to test ` unless the current GitHub user has maintainer authority. - Code review is code-only. Do not run pre-commit, unit tests, or E2E locally as part of the initial PR review unless explicitly instructed. -- Security vulnerabilities must not be triaged through public GitHub issues. Follow `SECURITY.md`. +- Surface security-related findings introduced or newly exposed by an unmerged + PR in that PR's normal review, using the same evidence, severity, and blocker + rules as other findings. A security classification alone does not require + private triage or suspend operator-authorized test dispatch. +- For pre-existing vulnerabilities independent of the PR, follow `SECURITY.md`: + retain detailed evidence privately and notify the operator without publishing + exploit details. Do not initiate external disclosure, contact PSIRT, or submit + a security report without explicit operator authorization. Maintainer authority means one of: @@ -651,6 +658,14 @@ Keep reviews proportional, scope-bound, and convergent: - Route pre-existing security defects through the private security process. Do not publish exploit details or make them blockers on the current PR. Route other pre-existing defects to a non-blocking follow-up. +- A pre-existing unsafe primitive does not make a new PR-owned exposure + pre-existing. Compare base and head: if the PR creates a new reachable path, + trusted sink, or security contract that makes the defect exploitable, surface + the regression in the PR review with its invariant, impact, fix, and verification. + Do not substitute a generic private-security notice for actionable feedback + or invent a `private_security_review_required` gate solely because the finding + is security-related. Existing review blockers still follow normal state rules; + this distinction does not waive findings or authorize tests otherwise forbidden. - Treat docs, skill drift, diagnostic wording, and test-strength feedback as non-blocking unless the published contract is materially false, the diagnostic causes an operational or safety failure, or missing coverage From 775d9071bc77196dba62c4eb024e96ecbf0fe7d3 Mon Sep 17 00:00:00 2001 From: Eric Curtin Date: Mon, 12 Oct 2026 17:55:49 +0000 Subject: [PATCH 10/32] feat(sdk/ts): add createdAtMs to SandboxRef (#4347) * feat(sdk/ts): add createdAtMs to SandboxRef Signed-off-by: Eric Curtin * docs(sdk/ts): document createdAtMs on SandboxRef Signed-off-by: Eric Curtin --------- Signed-off-by: Eric Curtin --- docs/sdk/typescript.mdx | 3 +++ sdk/typescript/README.md | 2 ++ sdk/typescript/src/client.test.ts | 16 ++++++++++++++++ sdk/typescript/src/client.ts | 14 ++++++++++---- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/docs/sdk/typescript.mdx b/docs/sdk/typescript.mdx index 312bc32a46..53d30312ee 100644 --- a/docs/sdk/typescript.mdx +++ b/docs/sdk/typescript.mdx @@ -130,6 +130,9 @@ if (deletion.outcome === 'accepted') { } ``` +Sandbox refs returned by `create`, `get`, and `list` include `createdAtMs`, the +creation time in epoch milliseconds. It is absent when the gateway omits it. + `client.sandbox` also supports streaming and interactive exec, TCP forwarding, SSH sessions, sandbox provider attachment, configuration, and policy. Close operation-scoped streams and forwarding handles when finished. The root client diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index e780a362ba..c92cfc1634 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -64,6 +64,8 @@ resolves to a different ID. Without that option, it waits for name absence, including any same-name replacement. Pass the same `workspace` to deletion and its wait when using a non-default workspace. +Sandbox refs from `create`, `get`, and `list` carry `createdAtMs` (epoch milliseconds), absent when the gateway omits it. + `connect()` constructs a lazy client; call `health()` when startup must verify gateway reachability. Static `oidcToken` and `edgeToken` values remain fixed for the client's lifetime. For long-running service automation, use the renewable diff --git a/sdk/typescript/src/client.test.ts b/sdk/typescript/src/client.test.ts index 45464409e4..52c73c3341 100644 --- a/sdk/typescript/src/client.test.ts +++ b/sdk/typescript/src/client.test.ts @@ -83,6 +83,22 @@ describe('sandbox SSH host identity', () => { }); }); +describe('sandbox creation time', () => { + it('maps created_time from get and list and leaves it absent when omitted', async () => { + let createdTime: { seconds: bigint; nanos: number } | undefined = { seconds: 1_700_000_000n, nanos: 123_000_000 }; + const sandbox = client({ + getSandbox: () => ({ sandbox: { metadata: { id: 'id', name: 'work', createdTime } } }), + listSandboxes: () => ({ sandboxes: [{ metadata: { id: 'id', name: 'work', createdTime } }] }), + }); + expect((await sandbox.get('work')).createdAtMs).toBe(1_700_000_000_123); + expect((await sandbox.list().all())[0]?.createdAtMs).toBe(1_700_000_000_123); + createdTime = undefined; + expect((await sandbox.get('work')).createdAtMs).toBeUndefined(); + createdTime = { seconds: 0n, nanos: 0 }; + expect((await sandbox.list().all())[0]?.createdAtMs).toBeUndefined(); + }); +}); + describe('deletion outcomes', () => { it('defaults to strict deletion and preserves accepted identity and unknown values', async () => { const flags: boolean[] = []; diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 587d4fc5b1..e9537e1125 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -49,6 +49,11 @@ function timestampMillis(timestamp: { seconds: bigint; nanos: number } | undefin return millis === 0n ? undefined : millis.toString(); } +function timestampEpochMs(timestamp: { seconds: bigint; nanos: number } | undefined): number | undefined { + const millis = timestampMillis(timestamp); + return millis ? Number(millis) : undefined; +} + // Generated protobuf message shapes that callers need to populate or round-trip // directly. Re-export these rather than re-curating parallel surfaces. export type { @@ -225,6 +230,8 @@ export interface SandboxRef { /** Service URLs returned by creation, keyed by service name. */ serviceUrls: Record; restartCount: number; + /** Creation time in epoch milliseconds; absent when the gateway omits it. */ + createdAtMs?: number; nextRestartAtMs?: number; mainProcessStartedAtMs?: number; } @@ -529,8 +536,6 @@ function sandboxRef(sandbox: Sandbox | undefined, serviceUrls: Record Date: Mon, 12 Oct 2026 18:40:10 +0000 Subject: [PATCH 11/32] fix(server): retry stop lifecycle writes after concurrent updates (#4395) The stop path wrote Stopping and Stopped with a single compare-and-swap. In HA deployments every replica applies compute driver watch events for the terminating runtime without the per-replica lifecycle gate, so a watch write landing between the re-read and the CAS failed the stop RPC with "update sandbox lifecycle failed due to concurrent modification" and left the sandbox in Stopping. Retry both writes on PersistenceError::Conflict up to STOP_PHASE_CAS_RETRY_LIMIT, re-reading the sandbox on each attempt. The Stopping write re-runs the stop preconditions and the name check; the Stopped write proceeds only while the record is still Stopping, returns an already Stopped record as-is, and aborts on any other phase. Gateway startup recovery of retained Stopping sandboxes now shares the same helper instead of writing against its pre-driver snapshot. Signed-off-by: Piotr Mlocek --- crates/openshell-server/src/compute/mod.rs | 257 ++++++++++++++++----- 1 file changed, 196 insertions(+), 61 deletions(-) diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 51b2c5e6e0..9cacfc41ef 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -201,6 +201,9 @@ mod traced_driver { const DELETE_PHASE_CAS_RETRY_LIMIT: usize = 3; const START_PHASE_CAS_RETRY_LIMIT: usize = 3; +/// Stop completion races driver watch events for the terminating runtime, +/// which every HA replica applies without the per-replica lifecycle gate. +const STOP_PHASE_CAS_RETRY_LIMIT: usize = 5; const SUPERVISOR_SESSION_CAS_RETRY_LIMIT: usize = 3; /// Serializes request-side lifecycle mutations for the same stable sandbox ID. @@ -1557,7 +1560,7 @@ impl ComputeRuntime { let sandbox_name = candidate.object_name().to_string(); let lifecycle_guard = self.lifecycle_gates.lock_for(&sandbox_id).await; let global_guard = self.lock_global_for_lifecycle(&lifecycle_guard).await; - let current = self + let mut current = self .store .get_message::(&sandbox_id) .await @@ -1569,40 +1572,71 @@ impl ComputeRuntime { )); } - provisioning_operation::ensure_operation_settled(¤t)?; - let phase = SandboxPhase::try_from(current.phase()).unwrap_or(SandboxPhase::Unknown); - if matches!(phase, SandboxPhase::Stopped | SandboxPhase::Completed) - || is_failed_main_process_result(¤t) - { - self.cleanup_stopped_sandbox_sessions(¤t) - .await - .map_err(Status::internal)?; - return Ok(current); - } - let automatic_restart = is_automatic_restart_transition(¤t); - if !matches!(phase, SandboxPhase::Ready | SandboxPhase::Stopping) && !automatic_restart { - return Err(Status::failed_precondition(format!( - "sandbox must be Ready or in an automatic restart to stop (current phase: {phase:?})" - ))); - } + let mut attempt = 1; + let (previous, stopping) = loop { + provisioning_operation::ensure_operation_settled(¤t)?; + let phase = SandboxPhase::try_from(current.phase()).unwrap_or(SandboxPhase::Unknown); + if matches!(phase, SandboxPhase::Stopped | SandboxPhase::Completed) + || is_failed_main_process_result(¤t) + { + self.cleanup_stopped_sandbox_sessions(¤t) + .await + .map_err(Status::internal)?; + return Ok(current); + } + let automatic_restart = is_automatic_restart_transition(¤t); + if !matches!(phase, SandboxPhase::Ready | SandboxPhase::Stopping) && !automatic_restart + { + return Err(Status::failed_precondition(format!( + "sandbox must be Ready or in an automatic restart to stop (current phase: {phase:?})" + ))); + } - let (previous, stopping) = if phase == SandboxPhase::Stopping { - // Acquiring the lifecycle gate proves that no local worker still - // owns this transition. Retry the idempotent driver operation. - (current.clone(), current) - } else { - let previous = current.clone(); - let stopping = self + if phase == SandboxPhase::Stopping { + // Acquiring the lifecycle gate proves that no local worker still + // owns this transition. Retry the idempotent driver operation. + break (current.clone(), current); + } + + match self .write_lifecycle_phase( ¤t, SandboxPhase::Stopping, "Stopping", "Sandbox stop requested", ) - .await?; - self.sandbox_index.update_from_sandbox(&stopping); - self.sandbox_watch_bus.notify(&sandbox_id); - (previous, stopping) + .await + { + Ok(stopping) => { + self.sandbox_index.update_from_sandbox(&stopping); + self.sandbox_watch_bus.notify(&sandbox_id); + break (current, stopping); + } + Err(crate::persistence::PersistenceError::Conflict { .. }) + if attempt < STOP_PHASE_CAS_RETRY_LIMIT => + { + attempt += 1; + current = self + .store + .get_message::(&sandbox_id) + .await + .map_err(|error| { + Status::internal(format!("fetch sandbox after stop conflict: {error}")) + })? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + if current.object_name() != sandbox_name { + return Err(Status::aborted( + "sandbox name changed during stop; retry explicitly", + )); + } + } + Err(error) => { + return Err(crate::grpc::persistence_error_to_status( + error, + "update sandbox lifecycle", + )); + } + } }; drop(global_guard); @@ -1664,28 +1698,7 @@ impl ComputeRuntime { match result { Ok(_) => { let _global_guard = self.lock_global_for_lifecycle(&lifecycle_guard).await; - let latest = self - .store - .get_message::(&sandbox_id) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .ok_or_else(|| Status::not_found("sandbox not found"))?; - let phase = SandboxPhase::try_from(latest.phase()).unwrap_or(SandboxPhase::Unknown); - let stopped = if phase == SandboxPhase::Stopped { - latest - } else if phase == SandboxPhase::Stopping { - self.write_lifecycle_phase( - &latest, - SandboxPhase::Stopped, - "Stopped", - "Sandbox compute is stopped", - ) - .await? - } else { - return Err(Status::aborted( - "sandbox lifecycle changed while stop completed", - )); - }; + let stopped = self.commit_sandbox_stopped(&sandbox_id).await?; self.cleanup_stopped_sandbox_sessions(&stopped) .await .map_err(Status::internal)?; @@ -2327,7 +2340,7 @@ impl ComputeRuntime { phase: SandboxPhase, reason: &str, message: &str, - ) -> Result { + ) -> Result { let sandbox_id = sandbox.object_id().to_string(); let expected_resource_version = sandbox_resource_version(sandbox); let reason = reason.to_string(); @@ -2347,7 +2360,60 @@ impl ComputeRuntime { }, ) .await - .map_err(|e| crate::grpc::persistence_error_to_status(e, "update sandbox lifecycle")) + } + + /// Commit `Stopped` after the driver confirmed the stop. + /// + /// Other replicas apply watch events for the terminating runtime without + /// this replica's lifecycle gate, so the CAS can lose to an unrelated + /// status write. Re-read and retry while the record is still `Stopping`. + async fn commit_sandbox_stopped(&self, sandbox_id: &str) -> Result { + let mut attempt = 1; + loop { + let latest = self + .store + .get_message::(sandbox_id) + .await + .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + match SandboxPhase::try_from(latest.phase()).unwrap_or(SandboxPhase::Unknown) { + SandboxPhase::Stopped => return Ok(latest), + SandboxPhase::Stopping => {} + _ => { + return Err(Status::aborted( + "sandbox lifecycle changed while stop completed", + )); + } + } + match self + .write_lifecycle_phase( + &latest, + SandboxPhase::Stopped, + "Stopped", + "Sandbox compute is stopped", + ) + .await + { + Ok(stopped) => return Ok(stopped), + Err(crate::persistence::PersistenceError::Conflict { + current_resource_version, + }) if attempt < STOP_PHASE_CAS_RETRY_LIMIT => { + debug!( + sandbox_id, + attempt, + current_resource_version, + "Retrying sandbox stop completion after a concurrent sandbox update" + ); + attempt += 1; + } + Err(error) => { + return Err(crate::grpc::persistence_error_to_status( + error, + "update sandbox lifecycle", + )); + } + } + } } async fn restore_lifecycle_snapshot(&self, owned: &Sandbox, previous: &Sandbox) -> bool { @@ -3574,15 +3640,7 @@ impl ComputeRuntime { ) .await { - Ok(_) => match self - .write_lifecycle_phase( - &sandbox, - SandboxPhase::Stopped, - "Stopped", - "Sandbox compute is stopped", - ) - .await - { + Ok(_) => match self.commit_sandbox_stopped(&sandbox_id).await { Ok(updated) => { self.sandbox_index.update_from_sandbox(&updated); self.sandbox_watch_bus.notify(updated.object_id()); @@ -11548,6 +11606,83 @@ mod tests { assert_eq!(driver.stop_calls(), 1); } + #[tokio::test] + async fn stop_retries_lifecycle_writes_after_concurrent_replica_write() { + let directory = tempfile::tempdir().expect("temporary database directory"); + let database_url = format!("sqlite://{}", directory.path().join("gateway.db").display()); + let store = Arc::new(Store::connect(&database_url).await.expect("connect store")); + let pool = sqlx::SqlitePool::connect(&database_url) + .await + .expect("connect conflict injector"); + sqlx::query("CREATE TABLE injected_conflicts (marker TEXT PRIMARY KEY)") + .execute(&pool) + .await + .expect("create conflict marker table"); + // Another replica applies a watch event between each lifecycle read + // and its CAS write: once before `Stopping`, once before `Stopped`. + for trigger in [ + "CREATE TRIGGER inject_replica_write_stopping \ + BEFORE UPDATE OF payload ON objects \ + WHEN instr(NEW.payload, CAST('Sandbox stop requested' AS BLOB)) > 0 \ + AND NOT EXISTS (SELECT 1 FROM injected_conflicts WHERE marker = 'stopping') \ + BEGIN \ + INSERT INTO injected_conflicts (marker) VALUES ('stopping'); \ + UPDATE objects SET resource_version = resource_version + 1 \ + WHERE object_type = OLD.object_type AND id = OLD.id; \ + SELECT RAISE(IGNORE); \ + END", + "CREATE TRIGGER inject_replica_write_stopped \ + BEFORE UPDATE OF payload ON objects \ + WHEN instr(NEW.payload, CAST('Sandbox compute is stopped' AS BLOB)) > 0 \ + AND NOT EXISTS (SELECT 1 FROM injected_conflicts WHERE marker = 'stopped') \ + BEGIN \ + INSERT INTO injected_conflicts (marker) VALUES ('stopped'); \ + UPDATE objects SET resource_version = resource_version + 1 \ + WHERE object_type = OLD.object_type AND id = OLD.id; \ + SELECT RAISE(IGNORE); \ + END", + ] { + sqlx::query(trigger) + .execute(&pool) + .await + .expect("install conflict trigger"); + } + + let driver = ControlledDriver::new(); + let mut runtime = test_runtime(driver.clone()).await; + runtime.store = store; + let sandbox = sandbox_record( + "sb-stop-replica-race", + "stop-replica-race", + SandboxPhase::Ready, + ); + runtime.store.put_message(&sandbox).await.unwrap(); + + let stopped = runtime + .stop_sandbox("default", sandbox.object_name()) + .await + .expect("stop must retry lifecycle writes after a concurrent write"); + + assert_eq!(stopped.phase(), SandboxPhase::Stopped as i32); + assert_eq!(driver.stop_calls(), 1); + let stored = runtime + .store + .get_message::(sandbox.object_id()) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.phase(), SandboxPhase::Stopped as i32); + let injected = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM injected_conflicts") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + injected, 2, + "both lifecycle writes must hit an injected conflict" + ); + pool.close().await; + } + #[tokio::test] async fn retained_starting_transition_retries_driver_operation() { let driver = ControlledDriver::new(); From 16c8ca5c434913a6e62586c4c31971a1744bdaff Mon Sep 17 00:00:00 2001 From: Oliver Calder Date: Mon, 12 Oct 2026 19:02:26 +0000 Subject: [PATCH 12/32] fix(docs): use lowercase "snap" as it is not a proper noun (#4387) Signed-off-by: Oliver Calder --- .agents/skills/test-release-canary/SKILL.md | 8 +-- .github/workflows/release-canary.yml | 10 ++-- .github/workflows/release-dev.yml | 4 +- AGENTS.md | 4 ++ CI.md | 6 +- deploy/man/openshell-gateway.8.md | 2 +- docs/CONTRIBUTING.mdx | 1 + docs/about/installation.mdx | 8 +-- docs/about/support-matrix.mdx | 4 +- docs/how-it-works/gateways/configuration.mdx | 4 +- docs/how-it-works/policies/prover.mdx | 8 +-- install.sh | 6 +- nix/test-guest/README.md | 2 +- rfc/0014-release-stability/build-matrix.md | 2 +- .../agents/gator/skills/gator-gate/SKILL.md | 2 +- skills/debug-openshell-cluster/SKILL.md | 4 +- snapcraft.yaml | 2 +- tasks/scripts/release.py | 2 +- tasks/scripts/test-install-sh.sh | 56 +++++++++---------- tasks/scripts/test-packaging-assets.sh | 12 ++-- tasks/scripts/test-snap-gateway-wrapper.sh | 2 +- 21 files changed, 77 insertions(+), 72 deletions(-) diff --git a/.agents/skills/test-release-canary/SKILL.md b/.agents/skills/test-release-canary/SKILL.md index ed5491e87f..fc6414eeed 100644 --- a/.agents/skills/test-release-canary/SKILL.md +++ b/.agents/skills/test-release-canary/SKILL.md @@ -17,7 +17,7 @@ The Release Canary (`.github/workflows/release-canary.yml`) smoke-tests the arti | `ubuntu-deb` | `ubuntu-latest` | Installs the dev Debian package, reaches the Docker gateway, and creates, executes in, and deletes a sandbox. | | `fedora` | `fedora:latest` container | Installs the dev RPM packages, reaches the Podman gateway, and creates, executes in, and deletes a sandbox. | | `ubuntu-snap-system-docker` | `ubuntu-latest` | Uses `install.sh` to install the snap from `latest/edge`, reuses system Docker, verifies the packaged prover version and a local policy boundary check, reaches the Docker gateway, creates, executes in, and deletes a sandbox, and verifies that the Docker snap is not installed. | -| `ubuntu-snap-docker-preflight` | `ubuntu-latest` | Verifies that `install.sh` rejects the OpenShell Snap path when Docker is absent or supplied by the Docker snap, without installing OpenShell. | +| `ubuntu-snap-docker-preflight` | `ubuntu-latest` | Verifies that `install.sh` rejects the OpenShell snap path when Docker is absent or supplied by the Docker snap, without installing OpenShell. | | `kubernetes` | `ubuntu-latest` + kind | Installs the dev Helm chart, reaches the in-cluster gateway, and creates, executes in, and deletes a sandbox using the published runtime images. | All canary jobs disable anonymous OpenShell telemetry. Host package jobs inject @@ -27,7 +27,7 @@ does not contribute to product usage metrics. The workflow sets `OPENSHELL_VERSION=dev` for every `install.sh` job. Positive jobs consume the rolling dev release produced by the triggering workflow. The -system-Docker Snap lane tracks `latest/edge`; the missing-Docker lane exits +system-Docker snap lane tracks `latest/edge`; the missing-Docker lane exits before installing a snap. The Debian and Kubernetes CLI lanes remove snapd so they continue to exercise the dev Debian package. Kubernetes pins the matching `0.0.0-dev` chart and `:dev` images. @@ -141,10 +141,10 @@ Loopback registration auto-derives the gateway name to `openshell` if `--name` i | Symptom | Likely cause | Where to look | |---|---|---| | `macos`/`ubuntu-deb`/`fedora` job fails on `install.sh` | Dev release missing an asset, checksum mismatch, or `install.sh` regression on this branch. | Job log around the `curl … install.sh \| sh` step. | -| Sandbox create or exec fails | Published sandbox and supervisor artifacts are missing, incompatible, or cannot establish the protected runtime channel. | Gateway logs plus Docker, Podman, VM, Snap, or Kubernetes runtime diagnostics for the job. | +| Sandbox create or exec fails | Published sandbox and supervisor artifacts are missing, incompatible, or cannot establish the protected runtime channel. | Gateway logs plus Docker, Podman, VM, snap, or Kubernetes runtime diagnostics for the job. | | `macos`/`ubuntu-deb`/`fedora` job fails on `openshell status` | Local gateway service did not start (systemd/brew/podman). Often a driver issue. | Service logs in the job log; `OPENSHELL_COMPUTE_DRIVER` env in the "Ensure …" step. | | `ubuntu-snap-system-docker` fails during `install.sh` | System Docker was unavailable to the runner user, the edge revision or automatic interfaces were unavailable, or the user gateway did not become reachable. | Failure diagnostics dump system Docker, snap service/connection/change state, user and legacy gateway journals, snap logs, and port 17670 listeners. | -| `ubuntu-snap-system-docker` fails during the prover checks | The prover artifact is missing or packaged for the wrong architecture, `openshell.prover` is not exposed or confined to read the test policies, or its solver linkage is not runnable. | The `Verify Snap installation` and `Check a policy boundary with the Snap prover` steps, plus `snap info openshell` and `snap connections openshell`. | +| `ubuntu-snap-system-docker` fails during the prover checks | The prover artifact is missing or packaged for the wrong architecture, `openshell.prover` is not exposed or confined to read the test policies, or its solver linkage is not runnable. | The `Install and check status` and `Check a policy boundary with the snap prover` steps, plus `snap info openshell` and `snap connections openshell`. | | `ubuntu-snap-docker-preflight` unexpectedly succeeds | The installer no longer fails before installing the OpenShell snap when Docker is absent or supplied by the Docker snap. | Inspect `install.log`, `docker-snap.log`, `snap list`, and snapd changes. | | `kubernetes` job fails on `helm install --wait` | Chart did not deploy in 5 min — usually image pull failure or readiness probe failing. | "Diagnostics on failure" step dumps `helm status`, manifest, pod describe, pod logs. | | `kubernetes` job fails on `kubectl wait` | Gateway pod stuck `CrashLoopBackOff` or `ImagePullBackOff`. | Diagnostics dump; check `:dev` image existence at `ghcr.io/nvidia/openshell/gateway`. | diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index a989d10e84..d60697a12a 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -192,7 +192,7 @@ jobs: docker rm -f "${FEDORA_CANARY_CONTAINER}" >/dev/null 2>&1 || true ubuntu-snap-system-docker: - name: Ubuntu Snap with system Docker + name: Ubuntu snap with system Docker if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest env: @@ -245,7 +245,7 @@ jobs: openshell gateway list | grep -F "https://127.0.0.1:17670" openshell status - - name: Check a policy boundary with the Snap prover + - name: Check a policy boundary with the snap prover run: | set -euo pipefail prover_dir=$(mktemp -d "$HOME/openshell-prover-canary.XXXXXX") @@ -275,7 +275,7 @@ jobs: openshell sandbox exec --name "$sandbox" --no-tty -- true openshell sandbox delete "$sandbox" - - name: Collect Snap diagnostics + - name: Collect snap diagnostics if: failure() run: | set +e @@ -294,7 +294,7 @@ jobs: sudo ss -ltnp '( sport = :17670 )' ubuntu-snap-docker-preflight: - name: Ubuntu Snap Docker preflight + name: Ubuntu snap Docker preflight if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest env: @@ -346,7 +346,7 @@ jobs: sudo snap list docker >/dev/null ! sudo snap list openshell >/dev/null 2>&1 - - name: Collect Snap diagnostics + - name: Collect snap diagnostics if: failure() run: | set +e diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 4e129cf200..e0ef052e3c 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -242,7 +242,7 @@ jobs: checkout-ref: ${{ github.sha }} build-snap: - name: Build Snap + name: Build snap needs: [compute-versions, build-binaries] uses: ./.github/workflows/snap-package.yml with: @@ -259,7 +259,7 @@ jobs: cargo-version: ${{ needs.compute-versions.outputs.cargo_version }} publish-snap: - name: Publish Snap + name: Publish snap needs: build-snap uses: ./.github/workflows/snap-publish.yml with: diff --git a/AGENTS.md b/AGENTS.md index 4ac21136a3..68e2324e11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -225,6 +225,10 @@ When behavior, commands, or development workflows change, review the related age - Fern PR previews run through `.github/workflows/branch-docs.yml`. Release Dev publishes `dev`, and Release Tag publishes an immutable stable version plus `latest`. Both production paths call `.github/workflows/sync-docs.yml` once. - Use the `update-docs-from-commits` skill to scan recent commits and draft doc updates. +## Terminology + +- Treat `snap` as a common noun. Lowercase it in ordinary prose, but capitalize it where normal English sentence or title casing requires. Preserve proper names such as Snap Store, Snapcraft, and snapd. + ## Security - Never commit secrets, API keys, or credentials. If a file looks like it contains secrets (`.env`, `credentials.json`, etc.), do not stage it. diff --git a/CI.md b/CI.md index de6c53511b..9edefde745 100644 --- a/CI.md +++ b/CI.md @@ -547,9 +547,9 @@ These workflows run after merge to publish dev/tagged artifacts and verify them. |---|---| | `.github/workflows/release-dev.yml` | Publishes the rolling `dev` build on every push to `main`. Builds gateway, sandbox, and supervisor images and binaries, packages, wheels, and pushes the Helm chart as `oci://ghcr.io/nvidia/openshell/helm-chart:0.0.0-dev` (plus an immutable `0.0.0-dev.` pin). Also dispatchable manually. | | `.github/workflows/release-tag.yml` | Publishes tagged stable releases and manually dispatched pre-releases. Its automatic tag trigger excludes `-pre.*`. Both require the currently implemented qualification profile to pass before publication; the summary identifies the remaining RFC 0014 coverage. Failed candidates retain build artifacts and evidence in Actions storage. Source-SHA OCI images remain available as qualification inputs. | -| `.github/workflows/snap-package.yml` | Builds Snap and component artifacts for Release Dev and Release Tag without Store credentials or publication. | -| `.github/workflows/snap-publish.yml` | Uploads existing Snap and component artifacts to the Store without rebuilding. Release Dev calls it directly for `latest/edge` after Snap builds and requires it to succeed before creating the dev release. Release Tag calls it for `latest/stable` only for stable releases, after qualification and release assembly succeed. | -| `.github/workflows/release-canary.yml` | Smoke-tests published dev artifacts in the `macos`, `ubuntu-deb`, `ubuntu-snap-system-docker`, `fedora`, and `kubernetes` (kind + Helm) jobs. Each job reaches its gateway and creates, exercises, and deletes a sandbox. The `ubuntu-snap-system-docker` job verifies a compatible system Docker lifecycle, while `ubuntu-snap-docker-preflight` tests fail-fast behavior when Docker is absent or supplied by the Docker snap. The positive Snap lane also runs a local policy containment check with the packaged prover. It runs automatically after `Release Dev` succeeds and supports manual dispatch (`gh workflow run release-canary.yml --ref `). See the `test-release-canary` skill for the playbook and local kind reproduction. | +| `.github/workflows/snap-package.yml` | Builds snap and component artifacts for Release Dev and Release Tag without Store credentials or publication. | +| `.github/workflows/snap-publish.yml` | Uploads existing snap and component artifacts to the Store without rebuilding. Release Dev calls it directly for `latest/edge` after snap builds and requires it to succeed before creating the dev release. Release Tag calls it for `latest/stable` only for stable releases, after qualification and release assembly succeed. | +| `.github/workflows/release-canary.yml` | Smoke-tests published dev artifacts in the `macos`, `ubuntu-deb`, `ubuntu-snap-system-docker`, `fedora`, and `kubernetes` (kind + Helm) jobs. Each job reaches its gateway and creates, exercises, and deletes a sandbox. The `ubuntu-snap-system-docker` job verifies a compatible system Docker lifecycle, while `ubuntu-snap-docker-preflight` tests fail-fast behavior when Docker is absent or supplied by the Docker snap. The positive snap lane also runs a local policy containment check with the packaged prover. It runs automatically after `Release Dev` succeeds and supports manual dispatch (`gh workflow run release-canary.yml --ref `). See the `test-release-canary` skill for the playbook and local kind reproduction. | ## Required status contexts diff --git a/deploy/man/openshell-gateway.8.md b/deploy/man/openshell-gateway.8.md index 2584b9358c..7e818a3370 100644 --- a/deploy/man/openshell-gateway.8.md +++ b/deploy/man/openshell-gateway.8.md @@ -148,7 +148,7 @@ Each executable receives only **-V**, with a five-second deadline and an 8 KiB o The Debian and Ubuntu systemd user unit runs preflight before certificate generation, while retaining its EnvironmentFile and bare ExecStart behavior. The -Snap wrapper replays its effective daemon arguments through preflight. It first +snap wrapper replays its effective daemon arguments through preflight. It first uses a nonempty OPENSHELL_GATEWAY_CONFIG. Otherwise it passes the canonical service-specific gateway.toml path whenever it exists or is a symlink. The legacy system service uses SNAP_COMMON/gateway.toml; the user service uses diff --git a/docs/CONTRIBUTING.mdx b/docs/CONTRIBUTING.mdx index e6676056a0..bd3e66350f 100644 --- a/docs/CONTRIBUTING.mdx +++ b/docs/CONTRIBUTING.mdx @@ -153,6 +153,7 @@ Use these consistently: | NVIDIA | Nvidia, nvidia | | OpenShell | Open Shell, openShell, Openshell, openshell | | mTLS | MTLS, mtls | +| snap, or Snap when required by sentence or title case | Snap as an inherent proper noun | | YAML | yaml, Yaml | ## Submitting Doc Changes diff --git a/docs/about/installation.mdx b/docs/about/installation.mdx index 36b03c943e..2a2057ddb6 100644 --- a/docs/about/installation.mdx +++ b/docs/about/installation.mdx @@ -86,7 +86,7 @@ The gateway reads `~/.config/openshell/gateway.toml` if it exists, otherwise the ## Linux -The script installs a Debian package on Debian and Ubuntu or an RPM package on Fedora and RHEL. Set `OPENSHELL_INSTALL_METHOD=snap` to install the [Snap](#snap) package instead; hosts that already have the OpenShell snap keep refreshing it. Linux packages require glibc 2.28 or newer. +The script installs a Debian package on Debian and Ubuntu or an RPM package on Fedora and RHEL. Set `OPENSHELL_INSTALL_METHOD=snap` to install the [snap](#snap) package instead; hosts that already have the OpenShell snap keep refreshing it. Linux packages require glibc 2.28 or newer. The gateway runs as a systemd user service at `https://127.0.0.1:17670` and reads `~/.config/openshell/gateway.toml`. @@ -142,7 +142,7 @@ openshell status Existing installations upgraded from the former `openshell.gateway` service retain their state under `/var/snap/openshell/common` through `openshell.system-gateway`. Existing HTTPS registrations continue to work because the endpoint and TLS identity do not change. -If this user still has an old HTTP registration, or has not previously been enrolled, wait for the system gateway to be active, copy its client credentials into the user's Snap state, and register its HTTPS endpoint: +If this user still has an old HTTP registration, or has not previously been enrolled, wait for the system gateway to be active, copy its client credentials into the user's snap state, and register its HTTPS endpoint: ```shell snap services openshell.system-gateway @@ -161,9 +161,9 @@ openshell gateway add https://127.0.0.1:17670 --local --name openshell openshell status ``` -Set `old_registration` to the name shown by `openshell gateway list`, and omit the variable assignment and `openshell gateway remove` when no registration exists. Older direct Snap instructions may have used `openshell-gateway`, while the install script uses `openshell`. +Set `old_registration` to the name shown by `openshell gateway list`, and omit the variable assignment and `openshell gateway remove` when no registration exists. Older direct snap instructions may have used `openshell-gateway`, while the install script uses `openshell`. -### Change the Snap gateway mode +### Change the snap gateway mode The user and system gateways have separate configuration, databases, and cryptographic identities. Changing modes does not migrate gateway or sandbox state and interrupts active sandbox sessions. Back up any state you need and delete active sandboxes before changing modes. diff --git a/docs/about/support-matrix.mdx b/docs/about/support-matrix.mdx index 3edbdafd0f..25546e0302 100644 --- a/docs/about/support-matrix.mdx +++ b/docs/about/support-matrix.mdx @@ -112,9 +112,9 @@ OpenShell publishes standalone `openshell-prover` release assets for manual down These artifacts are attached to GitHub releases. The Linux binaries are static and do not require glibc. All prover archives include the required solver linkage. -The Debian, RPM, Homebrew, and Snap packages also include the prover. Debian, +The Debian, RPM, Homebrew, and snap packages also include the prover. Debian, RPM, and Homebrew installations expose it as `openshell-prover`; use -`openshell.prover` with the Snap. The `openshell-prover` Snap alias requires +`openshell.prover` with the snap. The `openshell-prover` snap alias requires Store approval and may not be available. ## Runtimes diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index d3910f9fd3..e14131264e 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -65,7 +65,7 @@ Package-managed gateways use either built-in defaults or a package-seeded TOML f | Homebrew | `$XDG_CONFIG_HOME/openshell/gateway.toml` when it exists, otherwise the Homebrew prefix config such as `/opt/homebrew/var/openshell/gateway.toml`. | | Debian/Ubuntu | `$XDG_CONFIG_HOME/openshell/gateway.toml`, usually `~/.config/openshell/gateway.toml` for the systemd user service. | | Fedora/RHEL RPM | `$XDG_CONFIG_HOME/openshell/gateway.toml`, usually `~/.config/openshell/gateway.toml`; the systemd user service seeds this file from the packaged template on first start. | -| Snap | New installations use `$SNAP_USER_COMMON/.config/openshell/gateway.toml`, usually `~/snap/openshell/common/.config/openshell/gateway.toml`. Upgraded legacy installations retain `$SNAP_COMMON/gateway.toml`, usually `/var/snap/openshell/common/gateway.toml`. | +| snap | New installations use `$SNAP_USER_COMMON/.config/openshell/gateway.toml`, usually `~/snap/openshell/common/.config/openshell/gateway.toml`. Upgraded legacy installations retain `$SNAP_COMMON/gateway.toml`, usually `/var/snap/openshell/common/gateway.toml`. | The Fedora/RHEL RPM template leaves `[openshell.gateway].bind_address` unset. The gateway therefore uses its built-in `127.0.0.1:17670` primary listener. Host-networked Podman supervisors use that same loopback endpoint, so the primary listener does not need a wildcard address. Set `bind_address` explicitly only when clients must reach the primary multiplexed API through another interface. @@ -1335,7 +1335,7 @@ openshell-gateway config preflight -- --config /etc/openshell/gateway.toml --grp Debian and Ubuntu run preflight from the systemd user unit before local certificate generation. The unit still loads the `gateway.env` environment file and starts the -gateway with no configuration arguments. Snap replays the exact effective daemon +gateway with no configuration arguments. snap replays the exact effective daemon arguments through preflight. It gives a nonempty `OPENSHELL_GATEWAY_CONFIG` precedence; otherwise it validates and passes its canonical service-specific path only when that path exists in the filesystem. The legacy system service diff --git a/docs/how-it-works/policies/prover.mdx b/docs/how-it-works/policies/prover.mdx index 85a6096f6a..87bacbc940 100644 --- a/docs/how-it-works/policies/prover.mdx +++ b/docs/how-it-works/policies/prover.mdx @@ -36,8 +36,8 @@ contain access that the proposal risk check would flag. This page covers the boundary check. To learn about the proposal risk check, refer to [Policy Advisor](/how-it-works/policies/advisor). -For Snap installations, replace `openshell-prover` in these examples with -`openshell.prover`. The `openshell-prover` Snap alias requires Store approval +For snap installations, replace `openshell-prover` in these examples with +`openshell.prover`. The `openshell-prover` snap alias requires Store approval and may not be available. The prover remains independent of the gateway at runtime. If you only need the @@ -60,9 +60,9 @@ or MCP rules, the prover reports that it cannot check the policy instead of ignoring those rules. [What the Boundary Check Covers](#what-the-boundary-check-covers) describes each part and its limits. -The Homebrew, Debian, RPM, and Snap packages install the prover. Homebrew, +The Homebrew, Debian, RPM, and snap packages install the prover. Homebrew, Debian, and RPM expose it as `openshell-prover`; use `openshell.prover` with the -Snap. The CLI reads policy files on your machine, does not need a gateway, and +snap. The CLI reads policy files on your machine, does not need a gateway, and does not apply or approve policies. Create `boundary.yaml`, a boundary that allows reading `/usr` and `/etc`: diff --git a/install.sh b/install.sh index d58dd81a9e..620a0beda0 100755 --- a/install.sh +++ b/install.sh @@ -72,7 +72,7 @@ NOTES: Linux installs the Debian package on amd64/arm64 or the RPM packages on x86_64/aarch64, depending on the host package manager. Set OPENSHELL_INSTALL_METHOD=snap to install the OpenShell snap instead; hosts - that already have the OpenShell snap keep refreshing it. Snap installs use + that already have the OpenShell snap keep refreshing it. snap installs use latest/stable by default and latest/edge for dev, and do not support explicit release tags or prereleases. The OpenShell snap requires a running Docker Engine installed from a system package or Docker's package @@ -1255,7 +1255,7 @@ openshell_snap_channel() { case "${OPENSHELL_VERSION:-}" in dev) printf '%s\n' "latest/edge" ;; '') printf '%s\n' "latest/stable" ;; - *) error "Snap installs do not support OPENSHELL_VERSION=${OPENSHELL_VERSION}; use a native package" ;; + *) error "snap installs do not support OPENSHELL_VERSION=${OPENSHELL_VERSION}; use a native package" ;; esac } @@ -1327,7 +1327,7 @@ copy_snap_client_bundle() { done } -# Snap revisions that require mTLS ship the post-refresh hook that migrates +# snap revisions that require mTLS ship the post-refresh hook that migrates # older plaintext configs. snap_gateway_uses_mtls() { [ -e "${OPENSHELL_SNAP_DIR:-/snap/openshell/current}/meta/hooks/post-refresh" ] diff --git a/nix/test-guest/README.md b/nix/test-guest/README.md index 7f161c82fd..155b95305c 100644 --- a/nix/test-guest/README.md +++ b/nix/test-guest/README.md @@ -78,7 +78,7 @@ The root [`flake.nix`](../../flake.nix) exposes this directory as the `test-gues | Fedora 44 | No | Yes | Yes | Yes | `.rpm` | | Rocky Linux 9 | Yes | Yes | No | Yes | `.rpm` | -The `snapd` configuration is available for Ubuntu and prepares snapd for Snap +The `snapd` configuration is available for Ubuntu and prepares snapd for snap Store installation experiments. Combine it with `docker` to reproduce the system-Docker canary, or use it alone to verify that `install.sh` fails safely when Docker is absent. diff --git a/rfc/0014-release-stability/build-matrix.md b/rfc/0014-release-stability/build-matrix.md index 43ca9f768e..ecfdddfced 100644 --- a/rfc/0014-release-stability/build-matrix.md +++ b/rfc/0014-release-stability/build-matrix.md @@ -21,7 +21,7 @@ of release jobs. | Platform | Installation methods | Requirements | | --- | --- | --- | | macOS (Apple Silicon) | Homebrew | macOS 13.3 or later | -| Linux (x86_64, arm64) | APT/DEB, RPM, Snap | glibc 2.28 or later | +| Linux (x86_64, arm64) | APT/DEB, RPM, snap | glibc 2.28 or later | | Windows (x86_64, arm64) | MSI, WinGet | Documented minimum Windows version and MSVC toolchain | Package installers include the CLI, TUI, gateway, and supported local drivers. diff --git a/scripts/agents/gator/skills/gator-gate/SKILL.md b/scripts/agents/gator/skills/gator-gate/SKILL.md index efef39291c..99c6b711de 100644 --- a/scripts/agents/gator/skills/gator-gate/SKILL.md +++ b/scripts/agents/gator/skills/gator-gate/SKILL.md @@ -541,7 +541,7 @@ Validate small and concentrated work when it has clear motivation and one of the - TUI, CLI, or API quality-of-life improvement with a clear user path - Driver improvement that makes sandbox lifecycle management easier or more efficient - Documentation clarification, typo fix, errata, or missing documentation -- CI/CD/build/release improvement, including Snap, package, release, or test harness work +- CI/CD/build/release improvement, including snap, package, release, or test harness work Documentation changes from non-maintainers must not reorder ToC items, change fundamental hierarchy, or restructure docs without a clear maintainer-approved reason. diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index e19f36b9fa..2ed1381fe2 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -72,7 +72,7 @@ Common findings: - `No active gateway`: register one with `openshell gateway add `. - Connection refused: gateway process is not running, service exposure is wrong, or a port-forward/proxy is not active. - TLS/certificate errors: the endpoint scheme or trust chain is wrong, a CLI mTLS bundle does not match the gateway CA, a supervisor is missing the gateway CA, or TLS termination does not match the gateway listener. Workloads and supervisors should not contain a user TLS client certificate or private key. -- Fresh Snap installations use `openshell.user-gateway`; upgraded installations retain their system-owned state through `openshell.system-gateway`. Existing HTTPS registrations continue to work after the service rename. Follow the published Snap installation steps to enroll a new user, replace an old HTTP registration, or change modes safely through the `disable` maintenance state. If `gateway-mode=disable`, no local Snap gateway is expected to run. +- Fresh snap installations use `openshell.user-gateway`; upgraded installations retain their system-owned state through `openshell.system-gateway`. Existing HTTPS registrations continue to work after the service rename. Follow the published snap installation steps to enroll a new user, replace an old HTTP registration, or change modes safely through the `disable` maintenance state. If `gateway-mode=disable`, no local snap gateway is expected to run. - `Unauthenticated` from an edge or OIDC gateway: refresh stored credentials with `openshell gateway login [name]`, then retry. Use `gateway logout` only when intentionally clearing local credentials. - Operator credential retrieval requires direct gateway mTLS, `[openshell.gateway.mtls_auth] operator_enabled = true`, and a verified certificate with exact `OU=operator`. OIDC admin roles and forwarded certificate headers do not grant access. Do not combine the operator certificate with an `Authorization` header. Enabling this role grants gateway-wide administration; never repair a denial by issuing operator certificates to ordinary users. See the published gateway authentication and provider references. - A direct development endpoint with a private or self-signed certificate can be isolated with `--gateway-endpoint --gateway-insecure`; do not persist or recommend insecure verification for shared gateways. @@ -1070,7 +1070,7 @@ When handing results back to the user, include: ## Package Configuration Preflight -For a Debian, Ubuntu, or Snap gateway that stops before certificate generation or +For a Debian, Ubuntu, or snap gateway that stops before certificate generation or daemon startup, validate the selected configuration without starting the service: ```shell diff --git a/snapcraft.yaml b/snapcraft.yaml index 8eab454c26..1f8ac71e16 100644 --- a/snapcraft.yaml +++ b/snapcraft.yaml @@ -147,7 +147,7 @@ apps: daemon-scope: user install-mode: disable refresh-mode: endure - # Use persistent user-owned XDG roots shared with the Snap CLI and TUI. + # Use persistent user-owned XDG roots shared with the snap CLI and TUI. # Snapd provides the ephemeral XDG_RUNTIME_DIR automatically. environment: XDG_CONFIG_HOME: "$SNAP_USER_COMMON/.config" diff --git a/tasks/scripts/release.py b/tasks/scripts/release.py index 4d76f13048..60e9dacc9d 100644 --- a/tasks/scripts/release.py +++ b/tasks/scripts/release.py @@ -552,7 +552,7 @@ def build_parser() -> argparse.ArgumentParser: "--deb", action="store_true", help="Print Debian package version only." ) get_version_parser.add_argument( - "--snap", action="store_true", help="Print Snap package version only." + "--snap", action="store_true", help="Print snap package version only." ) get_version_parser.add_argument( "--rpm-version", action="store_true", help="Print RPM Version only." diff --git a/tasks/scripts/test-install-sh.sh b/tasks/scripts/test-install-sh.sh index 34f84ddd72..e9ec590850 100755 --- a/tasks/scripts/test-install-sh.sh +++ b/tasks/scripts/test-install-sh.sh @@ -162,7 +162,7 @@ if ! ( find_existing_native_openshell_bin() { return 1; } guard_native_to_snap_transition ) >"$out" 2>"$err"; then - echo "FAIL: Snap install without an existing native installation should continue" >&2 + echo "FAIL: snap install without an existing native installation should continue" >&2 cat "$err" >&2 || true exit 1 fi @@ -177,7 +177,7 @@ assert_native_to_snap_blocked() { UPGRADE_NOTICE_ACK="" guard_native_to_snap_transition ) >"$out" 2>"$err"; then - echo "FAIL: ${name}: expected native-to-Snap transition to be blocked" >&2 + echo "FAIL: ${name}: expected native-to-snap transition to be blocked" >&2 exit 1 fi if ! grep -Fq "detected existing non-snap OpenShell ${version} at /usr/bin/openshell" "$err"; then @@ -186,7 +186,7 @@ assert_native_to_snap_blocked() { exit 1 fi if ! grep -Fq "does not import state from a non-snap installation" "$err"; then - echo "FAIL: ${name}: missing isolated Snap state explanation" >&2 + echo "FAIL: ${name}: missing isolated snap state explanation" >&2 cat "$err" >&2 || true exit 1 fi @@ -201,12 +201,12 @@ if ! ( UPGRADE_NOTICE_ACK=1 guard_native_to_snap_transition ) >"$out" 2>"$err"; then - echo "FAIL: acknowledged native-to-Snap transition should continue" >&2 + echo "FAIL: acknowledged native-to-snap transition should continue" >&2 cat "$err" >&2 || true exit 1 fi if ! grep -Fq "continuing because OPENSHELL_ACK_BREAKING_UPGRADE=1 is set" "$err"; then - echo "FAIL: acknowledged native-to-Snap transition was not reported" >&2 + echo "FAIL: acknowledged native-to-snap transition was not reported" >&2 cat "$err" >&2 || true exit 1 fi @@ -215,7 +215,7 @@ if ( RELEASE_TAG="" target_uses_breaking_gateway_model ); then - echo "FAIL: Snap targets must not use the version-boundary upgrade guard" >&2 + echo "FAIL: snap targets must not use the version-boundary upgrade guard" >&2 exit 1 fi @@ -223,12 +223,12 @@ out="$(mktemp)" err="$(mktemp)" if ! OPENSHELL_VERSION=dev openshell_snap_channel >"$out" 2>"$err"; then - echo "FAIL: dev must select the latest/edge Snap channel" >&2 + echo "FAIL: dev must select the latest/edge snap channel" >&2 cat "$err" >&2 || true exit 1 fi if [ "$(cat "$out")" != "latest/edge" ]; then - echo "FAIL: dev must select the latest/edge Snap channel" >&2 + echo "FAIL: dev must select the latest/edge snap channel" >&2 exit 1 fi if [ -s "$err" ]; then @@ -238,12 +238,12 @@ if [ -s "$err" ]; then fi if ! OPENSHELL_VERSION="" openshell_snap_channel >"$out" 2>"$err"; then - echo "FAIL: unset OPENSHELL_VERSION must select the latest/stable Snap channel" >&2 + echo "FAIL: unset OPENSHELL_VERSION must select the latest/stable snap channel" >&2 cat "$err" >&2 || true exit 1 fi if [ "$(cat "$out")" != "latest/stable" ]; then - echo "FAIL: unset OPENSHELL_VERSION must select the latest/stable Snap channel" >&2 + echo "FAIL: unset OPENSHELL_VERSION must select the latest/stable snap channel" >&2 exit 1 fi if [ -s "$err" ]; then @@ -253,11 +253,11 @@ if [ -s "$err" ]; then fi if (OPENSHELL_VERSION=v1.2.3 openshell_snap_channel) >"$out" 2>"$err"; then - echo "FAIL: pinned release must not select a Snap channel" >&2 + echo "FAIL: pinned release must not select a snap channel" >&2 exit 1 fi -if ! grep -Fq "Snap installs do not support OPENSHELL_VERSION=v1.2.3" "$err"; then - echo "FAIL: pinned release Snap rejection was not explained" >&2 +if ! grep -Fq "snap installs do not support OPENSHELL_VERSION=v1.2.3" "$err"; then + echo "FAIL: pinned release snap rejection was not explained" >&2 cat "$err" >&2 exit 1 fi @@ -371,7 +371,7 @@ wait:user-gateway-listener register:user-gateway wait:gateway-status" if [ "$calls" != "$expected" ]; then - echo "FAIL: existing user-mode Snap refresh used the wrong service" >&2 + echo "FAIL: existing user-mode snap refresh used the wrong service" >&2 printf 'Expected:\n%s\nActual:\n%s\n' "$expected" "$calls" >&2 exit 1 fi @@ -426,11 +426,11 @@ if ( set_linux_target_runtime_dir() { :; } install_linux_snap ) >"$out" 2>"$err"; then - echo "FAIL: disabled Snap gateway mode should block installation" >&2 + echo "FAIL: disabled snap gateway mode should block installation" >&2 exit 1 fi if ! grep -Fq "OpenShell snap gateway mode is disabled" "$err"; then - echo "FAIL: disabled Snap gateway mode missing recovery guidance" >&2 + echo "FAIL: disabled snap gateway mode missing recovery guidance" >&2 cat "$err" >&2 || true exit 1 fi @@ -468,7 +468,7 @@ assert_snap_install_rejected() { set_linux_target_runtime_dir() { :; } install_linux_snap ) >"$out" 2>"$err"; then - echo "FAIL: ${name}: Snap installation should have been rejected" >&2 + echo "FAIL: ${name}: snap installation should have been rejected" >&2 exit 1 fi if ! grep -Fq "$expected" "$err"; then @@ -604,7 +604,7 @@ if ! grep -Fq "Docker daemon at /var/run/docker.sock did not become reachable as fi if ! grep -Fq '/snap/bin/openshell' "${ROOT}/install.sh"; then - echo "FAIL: Snap installs must use the Snap CLI explicitly" >&2 + echo "FAIL: snap installs must use the snap CLI explicitly" >&2 exit 1 fi @@ -619,14 +619,14 @@ if ! ( snap_gateway_uses_mtls() { return 0; } register_system_snap_gateway ) >"$out" 2>"$err"; then - echo "FAIL: Snap gateway registration should succeed" >&2 + echo "FAIL: snap gateway registration should succeed" >&2 cat "$err" >&2 || true exit 1 fi registration_calls="$(cat "$registration_calls_file")" if [ "$registration_calls" != "copy:client-bundle target:/snap/bin/openshell gateway add https://127.0.0.1:17670 --local --name openshell" ]; then - echo "FAIL: mTLS Snap gateway registration must copy the client bundle and use HTTPS" >&2 + echo "FAIL: mTLS snap gateway registration must copy the client bundle and use HTTPS" >&2 printf '%s\n' "$registration_calls" >&2 exit 1 fi @@ -639,18 +639,18 @@ if ! ( snap_gateway_uses_mtls() { return 1; } register_system_snap_gateway ) >"$out" 2>"$err"; then - echo "FAIL: legacy plaintext Snap gateway registration should succeed" >&2 + echo "FAIL: legacy plaintext snap gateway registration should succeed" >&2 cat "$err" >&2 || true exit 1 fi registration_calls="$(cat "$registration_calls_file")" if [ "$registration_calls" != "target:/snap/bin/openshell gateway add http://127.0.0.1:17670 --local --name openshell" ]; then - echo "FAIL: legacy Snap gateway registration must use HTTP without copying certificates" >&2 + echo "FAIL: legacy snap gateway registration must use HTTP without copying certificates" >&2 printf '%s\n' "$registration_calls" >&2 exit 1 fi if ! grep -Fq "without client authentication" "$err"; then - echo "FAIL: legacy Snap gateway registration must warn about unauthenticated access" >&2 + echo "FAIL: legacy snap gateway registration must warn about unauthenticated access" >&2 exit 1 fi @@ -698,16 +698,16 @@ chmod 644 "${snap_user_home}/snap/openshell/common/.local/state/openshell/tls/cl snap_user_tls="${snap_user_home}/snap/openshell/common/.local/state/openshell/tls" for file in ca.crt client/tls.crt client/tls.key; do if ! cmp -s "${snap_tls_src}/${file}" "${snap_user_tls}/${file}"; then - echo "FAIL: Snap client bundle copy missing ${file}" >&2 + echo "FAIL: snap client bundle copy missing ${file}" >&2 exit 1 fi if [[ -z $(find "${snap_user_tls}/${file}" -perm 600) ]]; then - echo "FAIL: Snap client bundle ${file} must be mode 0600" >&2 + echo "FAIL: snap client bundle ${file} must be mode 0600" >&2 exit 1 fi done if [[ -z $(find "$snap_user_tls" -maxdepth 0 -perm 700) ]]; then - echo "FAIL: Snap client bundle directory must be mode 0700" >&2 + echo "FAIL: snap client bundle directory must be mode 0700" >&2 exit 1 fi @@ -808,12 +808,12 @@ if ! ( print_gateway_add_output() { :; } register_user_snap_gateway ) >"$out" 2>"$err"; then - echo "FAIL: user Snap gateway registration should succeed" >&2 + echo "FAIL: user snap gateway registration should succeed" >&2 cat "$err" >&2 || true exit 1 fi if [[ $(cat "$registration_calls_file") != "target:/snap/bin/openshell gateway add https://127.0.0.1:17670 --local --name openshell" ]]; then - echo "FAIL: user Snap gateway registration must use its own local TLS bundle" >&2 + echo "FAIL: user snap gateway registration must use its own local TLS bundle" >&2 cat "$registration_calls_file" >&2 exit 1 fi diff --git a/tasks/scripts/test-packaging-assets.sh b/tasks/scripts/test-packaging-assets.sh index cf221fbee3..71baf77af4 100755 --- a/tasks/scripts/test-packaging-assets.sh +++ b/tasks/scripts/test-packaging-assets.sh @@ -157,15 +157,15 @@ for snap_file in \ assert_not_contains "$snap_file" "default-provider: docker" done if [[ -e "${ROOT}/snap/hooks/connect-plug-docker" ]]; then - echo "FAIL: obsolete Snap Docker connection hook must not exist" >&2 + echo "FAIL: obsolete snap Docker connection hook must not exist" >&2 exit 1 fi if [[ ! -x "$snap_install_hook" ]]; then - echo "FAIL: Snap install hook must be executable" >&2 + echo "FAIL: snap install hook must be executable" >&2 exit 1 fi if [[ ! -x "$snap_configure_hook" ]]; then - echo "FAIL: Snap configure hook must be executable" >&2 + echo "FAIL: snap configure hook must be executable" >&2 exit 1 fi assert_contains "$snapcraft" 'daemon-scope: system' @@ -173,7 +173,7 @@ assert_contains "$snapcraft" 'daemon-scope: user' assert_contains "$snapcraft" 'install-mode: disable' assert_not_contains "$snapcraft" 'install-mode: enable' if grep -Eq '^ gateway:$' "$snapcraft"; then - echo "FAIL: removed Snap gateway app must not remain declared" >&2 + echo "FAIL: removed snap gateway app must not remain declared" >&2 exit 1 fi assert_contains "$snapcraft" ' system-gateway:' @@ -187,7 +187,7 @@ assert_not_contains "$snapcraft" 'OPENSHELL_DB_URL: "sqlite:$SNAP_USER_COMMON' assert_not_contains "$snapcraft" 'OPENSHELL_LOCAL_TLS_DIR: "$SNAP_USER_COMMON' assert_contains "$snapcraft" 'refresh-mode: endure' if [[ ! -x "$snap_post_refresh_hook" ]]; then - echo "FAIL: Snap post-refresh hook must be executable" >&2 + echo "FAIL: snap post-refresh hook must be executable" >&2 exit 1 fi assert_not_contains "$ROOT/tasks/scripts/snap-gateway-wrapper.sh" 'OPENSHELL_DISABLE_TLS' @@ -211,7 +211,7 @@ if ! awk ' } END { exit !(in_prover && finished && command && alias && home && plug_count == 1) } ' "$snapcraft"; then - echo "FAIL: Snap prover app must expose the openshell-prover alias with only home access" >&2 + echo "FAIL: snap prover app must expose the openshell-prover alias with only home access" >&2 exit 1 fi assert_not_contains "$snap_install_docs" "snap connect openshell:home" diff --git a/tasks/scripts/test-snap-gateway-wrapper.sh b/tasks/scripts/test-snap-gateway-wrapper.sh index c39c6e9bc8..532bbe3e6d 100755 --- a/tasks/scripts/test-snap-gateway-wrapper.sh +++ b/tasks/scripts/test-snap-gateway-wrapper.sh @@ -64,7 +64,7 @@ assert_log "config preflight -- --trace|config=$common/gateway.toml|db=sqlite:$c generate-certs --output-dir $common/tls --server-san host.openshell.internal|config=$common/gateway.toml|db=sqlite:$common/gateway.db?mode=rwc|tls=$common/tls --trace|config=$common/gateway.toml|db=sqlite:$common/gateway.db?mode=rwc|tls=$common/tls" -# An operator-provided environment path takes precedence over the Snap +# An operator-provided environment path takes precedence over the snap # compatibility path, while CLI arguments are replayed unchanged. override="$work/override.toml" printf 'operator override\n' >"$override" From 66696eb1c8b411616dfee866f10c44550679fd1f Mon Sep 17 00:00:00 2001 From: Max Dubrinsky Date: Mon, 12 Oct 2026 19:05:43 +0000 Subject: [PATCH 13/32] feat(python): expose sandbox status conditions in the SDK (#4436) * feat(python): expose sandbox status conditions in the SDK Add SandboxConditionRef and surface status.conditions on SandboxStatusRef and SandboxRef. Callers need condition reasons and transition times to time terminal transitions and explain failures that happen before the main process starts, without reading the raw proto. Signed-off-by: Max Dubrinsky * test(python): use real gateway condition values in conditions fixtures Signed-off-by: Max Dubrinsky --------- Signed-off-by: Max Dubrinsky --- python/openshell/__init__.py | 2 ++ python/openshell/sandbox.py | 34 +++++++++++++++++++ python/openshell/sandbox_test.py | 58 ++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/python/openshell/__init__.py b/python/openshell/__init__.py index 1b2f276673..c99c3b48e0 100644 --- a/python/openshell/__init__.py +++ b/python/openshell/__init__.py @@ -16,6 +16,7 @@ Pager, Sandbox, SandboxClient, + SandboxConditionRef, SandboxError, SandboxRef, SandboxSession, @@ -51,6 +52,7 @@ "ProviderCredentialValue", "Sandbox", "SandboxClient", + "SandboxConditionRef", "SandboxError", "SandboxRef", "SandboxSession", diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index 20ce99263e..19a7ba3b84 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -452,6 +452,15 @@ def _resolve_secret(self) -> str: return value +@dataclass(frozen=True) +class SandboxConditionRef: + type: str + status: str + reason: str = "" + message: str = "" + transition_time_ms: int | None = None + + @dataclass(frozen=True) class SandboxStatusRef: phase: int @@ -460,6 +469,7 @@ class SandboxStatusRef: restart_count: int = 0 next_restart_at_ms: int | None = None main_process_started_at_ms: int | None = None + conditions: tuple[SandboxConditionRef, ...] = () class ServiceAuthorizationMode(IntEnum): @@ -538,6 +548,10 @@ def phase(self) -> int: def current_policy_version(self) -> int: return self.status.current_policy_version + @property + def conditions(self) -> tuple[SandboxConditionRef, ...]: + return self.status.conditions + @dataclass(frozen=True) class ExecChunk: @@ -1772,6 +1786,25 @@ def _serialize_python_callable( return base64.b64encode(payload).decode("ascii") +def _sandbox_conditions( + status: openshell_pb2.SandboxStatus | None, +) -> tuple[SandboxConditionRef, ...]: + if status is None: + return () + return tuple( + SandboxConditionRef( + type=condition.type, + status=condition.status, + reason=condition.reason, + message=condition.message, + transition_time_ms=condition.transition_time.ToMilliseconds() + if condition.HasField("transition_time") + else None, + ) + for condition in status.conditions + ) + + def _sandbox_ref( sandbox: openshell_pb2.Sandbox, service_urls: Mapping[str, str] | None = None, @@ -1802,6 +1835,7 @@ def _sandbox_ref( main_process_started_at_ms=status.main_process_started_time.ToMilliseconds() if status is not None and status.HasField("main_process_started_time") else None, + conditions=_sandbox_conditions(status), ), labels=sandbox.metadata.labels if sandbox.metadata else {}, created_from_workload_template=provenance, diff --git a/python/openshell/sandbox_test.py b/python/openshell/sandbox_test.py index 40fc7c205f..019d3cc674 100644 --- a/python/openshell/sandbox_test.py +++ b/python/openshell/sandbox_test.py @@ -28,6 +28,7 @@ Pager, Sandbox, SandboxClient, + SandboxConditionRef, SandboxError, SandboxRef, SandboxStatusRef, @@ -2750,6 +2751,63 @@ def test_sandbox_ref_includes_main_process_result() -> None: assert status.main_process_started_at_ms == 1_699_999_000_000 +def test_sandbox_ref_includes_conditions() -> None: + proto = _make_sandbox_proto("sandbox-1", "job-1") + + ready = proto.status.conditions.add() + ready.type = "Ready" + ready.status = "True" + ready.reason = "BackendReady" + ready.message = "Container is running" + ready.transition_time.FromMilliseconds(1_700_000_000_000) + + failed = proto.status.conditions.add() + failed.type = "Ready" + failed.status = "Unknown" + failed.reason = "InspectFailed" + failed.message = "Container inspect failed: connection refused" + + ref = _sandbox_ref(proto) + + assert ref.status.conditions == ( + SandboxConditionRef( + type="Ready", + status="True", + reason="BackendReady", + message="Container is running", + transition_time_ms=1_700_000_000_000, + ), + SandboxConditionRef( + type="Ready", + status="Unknown", + reason="InspectFailed", + message="Container inspect failed: connection refused", + transition_time_ms=None, + ), + ) + # Surfaces the same conditions through SandboxRef directly. + assert ref.conditions == ref.status.conditions + + +def test_sandbox_ref_conditions_default_to_empty() -> None: + ref = _sandbox_ref(_make_sandbox_proto("sandbox-1", "job-1")) + + assert ref.status.conditions == () + assert ref.conditions == () + + +def test_direct_sandbox_ref_construction_defaults_conditions() -> None: + ref = SandboxRef( + id="sandbox-1", + name="job-1", + workspace="default", + status=SandboxStatusRef(phase=2, current_policy_version=0), + ) + + assert ref.status.conditions == () + assert ref.conditions == () + + def test_returned_labels_are_immutable() -> None: proto = _make_sandbox_proto("sandbox-1", "job-1", {"aiq": "deep-research"}) ref = _sandbox_ref(proto) From e326c10dded1d623474e08779a4bb2dadc70e476 Mon Sep 17 00:00:00 2001 From: Eric Curtin Date: Mon, 12 Oct 2026 19:06:21 +0000 Subject: [PATCH 14/32] fix(python): close client when Sandbox entry fails (#4367) * fix(python): close client when Sandbox entry fails Signed-off-by: Eric Curtin * test(python): share client patch helper Signed-off-by: Eric Curtin --------- Signed-off-by: Eric Curtin --- python/openshell/sandbox.py | 25 +++++-- python/openshell/sandbox_cleanup_test.py | 87 ++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 7 deletions(-) diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index 19a7ba3b84..e9c840e481 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -1643,7 +1643,7 @@ def __enter__(self) -> Sandbox: "name, labels, and workload_template cannot be set when attaching to an existing sandbox" ) - client = SandboxClient.from_active_cluster( + self._client = SandboxClient.from_active_cluster( cluster=self._cluster, timeout=self._timeout, auto_refresh=self._auto_refresh, @@ -1651,8 +1651,17 @@ def __enter__(self) -> Sandbox: insecure=self._insecure, client_credentials=self._client_credentials, ) - self._client = client + try: + self._open_session(self._client) + except BaseException: + # Python skips __exit__ when __enter__ raises, so release here + # and keep the original error even if the close fails. + with contextlib.suppress(Exception): + self._release() + raise + return self + def _open_session(self, client: SandboxClient) -> None: if self._sandbox_input is None and self._workload_template is not None: self._session = client.create_session_from_template( workspace=self._workspace, @@ -1684,7 +1693,12 @@ def __enter__(self) -> Sandbox: ) self._session = SandboxSession(client, ready) - return self + def _release(self) -> None: + client = self._client + self._session = None + self._client = None + if client is not None: + client.close() def __exit__(self, *args: object) -> None: try: @@ -1708,10 +1722,7 @@ def __exit__(self, *args: object) -> None: f"unsupported deletion outcome: {result.outcome}" ) finally: - if self._client is not None: - self._client.close() - self._session = None - self._client = None + self._release() def exec( self, diff --git a/python/openshell/sandbox_cleanup_test.py b/python/openshell/sandbox_cleanup_test.py index 7d38fadf36..4cf558251c 100644 --- a/python/openshell/sandbox_cleanup_test.py +++ b/python/openshell/sandbox_cleanup_test.py @@ -156,3 +156,90 @@ def test_context_cleanup_handles_absence_and_intercepted_errors( assert state.closed == [True] assert managed._client is None assert managed._session is None + + +class _EntryFailureClient: + """Fails the named client call and records close().""" + + def __init__(self, fail_at, close_error=None): + self.fail_at = fail_at + self.close_error = close_error + self.closed = 0 + self.error = RuntimeError(f"{fail_at} failed") + + def _step(self, name): + if name == self.fail_at: + raise self.error + return SimpleNamespace(sandbox=SimpleNamespace(name="s1")) + + def create_session(self, **_kwargs): + return self._step("create_session") + + def create_session_from_template(self, **_kwargs): + return self._step("create_session_from_template") + + def get_session(self, *_args, **_kwargs): + return self._step("get_session") + + def wait_ready(self, *_args, **_kwargs): + self._step("wait_ready") + return SimpleNamespace(name="s1", workspace="default") + + def close(self): + self.closed += 1 + if self.close_error is not None: + raise self.close_error + + +def _use_client(monkeypatch, client): + monkeypatch.setattr( + SandboxClient, "from_active_cluster", classmethod(lambda _cls, **_kw: client) + ) + + +@pytest.mark.parametrize( + ("fail_at", "kwargs"), + [ + ("create_session", {}), + ("create_session_from_template", {"workload_template": "t"}), + ("get_session", {"sandbox": "existing"}), + ("wait_ready", {}), + ("wait_ready", {"sandbox": "existing"}), + ], +) +def test_context_entry_failure_closes_client(monkeypatch, fail_at, kwargs): + client = _EntryFailureClient(fail_at) + _use_client(monkeypatch, client) + managed = Sandbox(workspace="default", delete_on_exit=False, **kwargs) + + with pytest.raises(RuntimeError) as observed, managed: + pass + + assert observed.value is client.error + assert client.closed == 1 + assert managed._client is None + assert managed._session is None + + +def test_context_entry_failure_keeps_original_error_when_close_fails(monkeypatch): + client = _EntryFailureClient("wait_ready", close_error=OSError("close failed")) + _use_client(monkeypatch, client) + managed = Sandbox(workspace="default") + + with pytest.raises(RuntimeError) as observed, managed: + pass + + assert observed.value is client.error + assert client.closed == 1 + assert managed._client is None + assert managed._session is None + + +def test_context_closes_client_once_on_success(monkeypatch): + client = _EntryFailureClient(None) + _use_client(monkeypatch, client) + + with Sandbox(workspace="default", delete_on_exit=False): + assert client.closed == 0 + + assert client.closed == 1 From f6a39833c34f2ff9b74d07c8c87370f86ffc2a49 Mon Sep 17 00:00:00 2001 From: alangou Date: Mon, 12 Oct 2026 19:08:34 +0000 Subject: [PATCH 15/32] fix(network): enforce consistent HTTP framing (#4414) * fix(network): validate chunked message framing Signed-off-by: Adrien Langou * fix(network): preserve chunk extension whitespace Signed-off-by: Adrien Langou --------- Signed-off-by: Adrien Langou --- .../src/l7/http.rs | 144 +++++++- .../src/l7/rest.rs | 314 ++++++++++++------ .../src/l7/rest/http_response.rs | 37 ++- .../openshell-supervisor-network/src/proxy.rs | 12 +- 4 files changed, 367 insertions(+), 140 deletions(-) diff --git a/crates/openshell-supervisor-network/src/l7/http.rs b/crates/openshell-supervisor-network/src/l7/http.rs index 9be6e7d891..6b26c7d961 100644 --- a/crates/openshell-supervisor-network/src/l7/http.rs +++ b/crates/openshell-supervisor-network/src/l7/http.rs @@ -9,6 +9,88 @@ use tokio::io::{AsyncRead, AsyncReadExt}; const READ_BUF_SIZE: usize = 8192; +pub fn parse_chunk_size_line(line: &[u8]) -> Result { + let line = + std::str::from_utf8(line).map_err(|_| miette!("Invalid UTF-8 in HTTP chunk-size line"))?; + if line + .bytes() + .any(|byte| matches!(byte, b'\r' | b'\n' | 0x7f) || (byte < b' ' && byte != b'\t')) + { + return Err(miette!( + "Invalid HTTP chunk-size line: contains a control byte" + )); + } + + // RFC 9112 permits bad whitespace immediately before a chunk-extension + // semicolon. Normalize only that suffix: leading whitespace, whitespace + // inside the size, and trailing whitespace without an extension remain + // invalid at this trust boundary. + let size_token = line + .split_once(';') + .map_or(line, |(size, _)| size.trim_end_matches([' ', '\t'])); + if size_token.is_empty() || !size_token.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(miette!("Invalid HTTP chunk size token")); + } + + usize::from_str_radix(size_token, 16) + .map_err(|_| miette!("HTTP chunk size exceeds platform limit")) +} + +pub fn validate_chunk_trailer_line(line: &[u8]) -> Result<()> { + if line.is_empty() { + return Ok(()); + } + if line + .iter() + .any(|byte| matches!(*byte, b'\r' | b'\n' | 0x7f)) + { + return Err(miette!( + "Invalid HTTP chunk trailer: contains a control byte" + )); + } + + let colon = line + .iter() + .position(|byte| *byte == b':') + .ok_or_else(|| miette!("Invalid HTTP chunk trailer: missing colon"))?; + let (name, value) = line.split_at(colon); + if name.is_empty() || !name.iter().all(|byte| is_http_token_byte(*byte)) { + return Err(miette!("Invalid HTTP chunk trailer field name")); + } + if !value[1..] + .iter() + .all(|byte| is_http_field_value_byte(*byte)) + { + return Err(miette!("Invalid HTTP chunk trailer field value")); + } + Ok(()) +} + +fn is_http_token_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) +} + +fn is_http_field_value_byte(byte: u8) -> bool { + byte == b'\t' || (b' '..=b'~').contains(&byte) || byte >= 0x80 +} + pub async fn read_body_for_inspection( client: &mut C, request: &mut L7Request, @@ -113,17 +195,7 @@ async fn read_chunked_body_for_inspection( loop { let size_line_end = read_chunked_line(client, &mut raw, pos, max_body_bytes).await?; - let size_line = std::str::from_utf8(&raw[pos..size_line_end]) - .into_diagnostic() - .map_err(|_| miette!("Invalid UTF-8 in HTTP chunk-size line"))?; - let size_token = size_line - .split(';') - .next() - .map(str::trim) - .unwrap_or_default(); - let chunk_size = usize::from_str_radix(size_token, 16) - .into_diagnostic() - .map_err(|_| miette!("Invalid HTTP chunk size token: {size_token:?}"))?; + let chunk_size = parse_chunk_size_line(&raw[pos..size_line_end])?; pos = size_line_end + 2; if decoded.len().saturating_add(chunk_size) > max_body_bytes { @@ -136,6 +208,7 @@ async fn read_chunked_body_for_inspection( loop { let trailer_end = read_chunked_line(client, &mut raw, pos, max_body_bytes).await?; let trailer_line = &raw[pos..trailer_end]; + validate_chunk_trailer_line(trailer_line)?; pos = trailer_end + 2; if trailer_line.is_empty() { request.raw_header.truncate(header_end); @@ -230,6 +303,48 @@ mod tests { } } + #[test] + fn chunk_framing_helpers_reject_ambiguous_lines() { + assert_eq!(parse_chunk_size_line(b"a;ext=value").unwrap(), 10); + for line in [ + b"".as_slice(), + b"+0", + b"+0 ;ext=value", + b" 0", + b" 0;ext=value", + b"\t0;ext=value", + b"0 ", + b"0\t", + b"0 \t1;ext=value", + b"0\n", + b"0\rX", + b"0;ext=bad\nvalue", + ] { + assert!(parse_chunk_size_line(line).is_err(), "accepted {line:?}"); + } + + for line in [ + b"Malformed-Trailer".as_slice(), + b"Bad Name: value", + b"X-Test: value\nGET /hidden", + b"X-Test: value\0", + ] { + assert!( + validate_chunk_trailer_line(line).is_err(), + "accepted {line:?}" + ); + } + validate_chunk_trailer_line(b"").unwrap(); + validate_chunk_trailer_line(b"X-Test: value").unwrap(); + } + + #[test] + fn chunk_size_allows_bws_before_extension() { + for line in [b"a ;ext=value".as_slice(), b"a\t;ext=value"] { + assert_eq!(parse_chunk_size_line(line).unwrap(), 10, "line {line:?}"); + } + } + #[tokio::test] async fn chunked_inspection_preserves_pipelined_request() { let next = b"DELETE /next HTTP/1.1\r\nHost: example.test\r\n\r\n"; @@ -300,9 +415,16 @@ mod tests { for (encoded, expected) in [ (b"z\r\n".as_slice(), "Invalid HTTP chunk size token"), (b"\xff\r\n", "Invalid UTF-8 in HTTP chunk-size line"), + (b"0\n\r\n\r\n", "chunk-size line"), + (b"+0\r\n\r\n", "chunk size token"), (b"1\r\nx!!", "HTTP chunk payload missing terminating CRLF"), (b"1\r", "HTTP chunked body ended before terminator"), (b"5\r\nhe", "HTTP chunked body ended before terminator"), + ( + b"0\r\nA: b\n\r\nGET /hidden HTTP/1.1\r\nHost: hidden.example\r\n\r\n", + "chunk trailer", + ), + (b"0\r\nMalformed-Trailer\r\n\r\n", "chunk trailer"), ( b"0\r\nX-Trailer: unfinished", "HTTP chunked body ended before terminator", diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index 07d3893df1..7b85942666 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -21,6 +21,7 @@ use http_response::{ }; use crate::l7::EndpointObserver; +use crate::l7::http::{parse_chunk_size_line, validate_chunk_trailer_line}; use crate::l7::provider::{BodyLength, L7Provider, L7Request, RelayOutcome}; use crate::opa::PolicyGenerationGuard; use aws_sigv4::http_request::SignableBody; @@ -1135,16 +1136,12 @@ where .into_diagnostic()?; let overflow = &req.raw_header[header_end..]; - if !overflow.is_empty() { - if let Some(guard) = options.generation_guard { - guard.ensure_current()?; - } - upstream.write_all(overflow).await.into_diagnostic()?; - } - let overflow_len = overflow.len() as u64; - match req.body_length { BodyLength::ContentLength(len) => { + if !overflow.is_empty() { + write_body_bytes(upstream, overflow, options).await?; + } + let overflow_len = overflow.len() as u64; let remaining = len.saturating_sub(overflow_len); if remaining > 0 { relay_fixed( @@ -1157,15 +1154,14 @@ where } } BodyLength::Chunked => { - relay_chunked( - client, - upstream, - &req.raw_header[header_end..], - options.generation_guard, - ) - .await?; + relay_chunked(client, upstream, overflow, options.generation_guard) + .await?; + } + BodyLength::None => { + if !overflow.is_empty() { + write_body_bytes(upstream, overflow, options).await?; + } } - BodyLength::None => {} } } @@ -1241,31 +1237,25 @@ where .into_diagnostic()?; let overflow = &req.raw_header[header_end..]; - if !overflow.is_empty() { - if let Some(guard) = options.generation_guard { - guard.ensure_current()?; - } - upstream.write_all(overflow).await.into_diagnostic()?; - } - let overflow_len = overflow.len() as u64; - match req.body_length { BodyLength::ContentLength(len) => { + if !overflow.is_empty() { + write_body_bytes(upstream, overflow, options).await?; + } + let overflow_len = overflow.len() as u64; let remaining = len.saturating_sub(overflow_len); if remaining > 0 { relay_fixed(client, upstream, remaining, options.generation_guard).await?; } } BodyLength::Chunked => { - relay_chunked( - client, - upstream, - &req.raw_header[header_end..], - options.generation_guard, - ) - .await?; + relay_chunked(client, upstream, overflow, options.generation_guard).await?; + } + BodyLength::None => { + if !overflow.is_empty() { + write_body_bytes(upstream, overflow, options).await?; + } } - BodyLength::None => {} } } upstream.flush().await.into_diagnostic()?; @@ -1467,15 +1457,7 @@ where let size_line = read_chunked_line(client, already_read, &mut read_state, generation_guard) .await .map_err(CollectChunkedError::into_report)?; - let size_line = std::str::from_utf8(&size_line) - .map_err(|_| miette!("Invalid UTF-8 in chunk-size line"))?; - let size_token = size_line - .split(';') - .next() - .map(str::trim) - .unwrap_or_default(); - let chunk_size = usize::from_str_radix(size_token, 16) - .map_err(|_| miette!("Invalid chunk size token: {size_token:?}"))?; + let chunk_size = parse_chunk_size_line(&size_line)?; if chunk_size == 0 { write_guarded_chunk(upstream, &scanner.finish()?, options).await?; @@ -1485,6 +1467,7 @@ where read_chunked_line(client, already_read, &mut read_state, generation_guard) .await .map_err(CollectChunkedError::into_report)?; + validate_chunk_trailer_line(&trailer)?; if contains_reserved_credential_marker_bytes(&trailer) { return Err(BodyCredentialError::Trailer.into()); } @@ -1714,15 +1697,7 @@ fn chunked_body_is_fully_buffered(bytes: &[u8]) -> bool { return false; }; let line_end = pos + line_end; - let Ok(size_line) = std::str::from_utf8(&bytes[pos..line_end]) else { - return false; - }; - let size_token = size_line - .split(';') - .next() - .map(str::trim) - .unwrap_or_default(); - let Ok(chunk_size) = usize::from_str_radix(size_token, 16) else { + let Ok(chunk_size) = parse_chunk_size_line(&bytes[pos..line_end]) else { return false; }; pos = line_end + 2; @@ -2059,17 +2034,7 @@ async fn collect_chunked_body( .map_err(|error| { error.context(|e| miette!("Chunked body ended before chunk-size line: {e}")) })?; - let size_line = std::str::from_utf8(&size_line).map_err(|_| { - CollectChunkedError::Failed(miette!("Invalid UTF-8 in chunk-size line")) - })?; - let size_token = size_line - .split(';') - .next() - .map(str::trim) - .unwrap_or_default(); - let chunk_size = usize::from_str_radix(size_token, 16).map_err(|_| { - CollectChunkedError::Failed(miette!("Invalid chunk size token: {size_token:?}")) - })?; + let chunk_size = parse_chunk_size_line(&size_line).map_err(CollectChunkedError::Failed)?; if chunk_size == 0 { let trailer_line = @@ -2080,6 +2045,7 @@ async fn collect_chunked_body( miette!("Chunked body ended before trailer terminator: {e}") }) })?; + validate_chunk_trailer_line(&trailer_line).map_err(CollectChunkedError::Failed)?; if trailer_line.is_empty() { return Ok(body); } @@ -3252,13 +3218,13 @@ where /// boundaries so we can stop exactly at the end of the current message body. /// Handles chunk extensions and trailers per RFC 7230. /// -/// `already_forwarded` are overflow bytes that were already written to the -/// writer during header parsing. The parser consumes them before reading more -/// bytes so it can detect boundaries without forwarding them twice. +/// `already_read` are overflow bytes buffered while parsing the headers. They +/// are validated here before being written so ambiguous framing cannot reach +/// the peer. async fn relay_chunked( reader: &mut R, writer: &mut W, - already_forwarded: &[u8], + already_read: &[u8], generation_guard: Option<&PolicyGenerationGuard>, ) -> Result<()> where @@ -3266,7 +3232,7 @@ where W: AsyncWrite + Unpin, { let started_at = std::time::Instant::now(); - let mut input = ChunkedRelayInput::new(already_forwarded); + let mut input = ChunkedRelayInput::new(already_read); let mut chunk_count = 0usize; let mut chunk_payload_bytes = 0usize; @@ -3278,17 +3244,7 @@ where "Chunk-size line exceeds limit", ) .await?; - let size_line = std::str::from_utf8(&size_line) - .into_diagnostic() - .map_err(|_| miette!("Invalid UTF-8 in chunk-size line"))?; - let size_token = size_line - .split(';') - .next() - .map(str::trim) - .unwrap_or_default(); - let chunk_size = usize::from_str_radix(size_token, 16) - .into_diagnostic() - .map_err(|_| miette!("Invalid chunk size token: {size_token:?}"))?; + let chunk_size = parse_chunk_size_line(&size_line)?; if chunk_size == 0 { let mut trailer_count = 0usize; @@ -3301,6 +3257,7 @@ where "Chunk trailer line exceeds limit", ) .await?; + validate_chunk_trailer_line(&trailer_line)?; trailer_bytes = trailer_bytes .checked_add(trailer_line.len() + 2) .ok_or_else(|| miette!("Chunk trailer size overflow"))?; @@ -3357,16 +3314,16 @@ where } struct ChunkedRelayInput<'a> { - already_forwarded: &'a [u8], - already_forwarded_pos: usize, + already_read: &'a [u8], + already_read_pos: usize, pending: Vec, } impl<'a> ChunkedRelayInput<'a> { - fn new(already_forwarded: &'a [u8]) -> Self { + fn new(already_read: &'a [u8]) -> Self { Self { - already_forwarded, - already_forwarded_pos: 0, + already_read, + already_read_pos: 0, pending: Vec::with_capacity(RELAY_BUF_SIZE), } } @@ -3409,19 +3366,21 @@ impl<'a> ChunkedRelayInput<'a> { R: AsyncRead + Unpin, W: AsyncWrite + Unpin, { - if self.already_forwarded_pos < self.already_forwarded.len() { - let available = self.already_forwarded.len() - self.already_forwarded_pos; - let consumed = available.min(remaining); - self.already_forwarded_pos += consumed; - remaining -= consumed; - } - while remaining > 0 { if self.pending.len() >= RELAY_BUF_SIZE { self.flush_pending(writer, generation_guard).await?; } let available = RELAY_BUF_SIZE - self.pending.len(); let to_read = remaining.min(available); + if self.already_read_pos < self.already_read.len() { + let buffered = to_read.min(self.already_read.len() - self.already_read_pos); + let end = self.already_read_pos + buffered; + self.pending + .extend_from_slice(&self.already_read[self.already_read_pos..end]); + self.already_read_pos = end; + remaining -= buffered; + continue; + } let start = self.pending.len(); self.pending.resize(start + to_read, 0); let read = reader @@ -3445,9 +3404,10 @@ impl<'a> ChunkedRelayInput<'a> { where R: AsyncRead + Unpin, { - if self.already_forwarded_pos < self.already_forwarded.len() { - let byte = self.already_forwarded[self.already_forwarded_pos]; - self.already_forwarded_pos += 1; + if self.already_read_pos < self.already_read.len() { + let byte = self.already_read[self.already_read_pos]; + self.already_read_pos += 1; + self.pending.push(byte); return Ok(byte); } let byte = match reader.read_u8().await { @@ -4205,6 +4165,86 @@ mod tests { ); } + #[tokio::test] + async fn relay_chunked_rejects_ambiguous_framing_before_forwarding() { + for wire in [ + b"0\n\r\n\r\n".as_slice(), + b"+0\r\n\r\n", + b" 0\r\n\r\n", + b"0 \r\n\r\n", + b"0\rX\r\n\r\n", + b"0\r\nMalformed-Trailer\r\n\r\n", + b"4\r\nAAAA\r\n0\r\nA: b\n\r\nGET /hidden HTTP/1.1\r\nHost: hidden.example\r\n\r\n", + ] { + for buffered in [false, true] { + let input = if buffered { Vec::new() } else { wire.to_vec() }; + let already_read = if buffered { wire } else { &[] }; + let mut reader = CountingReader::new(input); + let mut writer = CountingWriter::default(); + + let error = relay_chunked(&mut reader, &mut writer, already_read, None) + .await + .expect_err("ambiguous chunked framing must fail closed"); + + assert!( + error.to_string().contains("chunk"), + "unexpected error: {error}" + ); + assert!( + !writer + .bytes + .windows(b"GET /hidden".len()) + .any(|window| window == b"GET /hidden"), + "the hidden request reached the peer (buffered={buffered})" + ); + } + } + } + + #[tokio::test] + async fn request_relay_does_not_forward_smuggled_request_from_buffered_body() { + let mut raw_header = + b"POST /allowed HTTP/1.1\r\nHost: allowed.example\r\nTransfer-Encoding: chunked\r\n\r\n" + .to_vec(); + raw_header.extend_from_slice( + b"4\r\nAAAA\r\n0\r\nA: b\n\r\nGET /hidden HTTP/1.1\r\nHost: hidden.example\r\n\r\n", + ); + let request = L7Request { + action: "POST".into(), + target: "/allowed".into(), + query_params: HashMap::new(), + raw_header, + body_length: BodyLength::Chunked, + }; + let (app, mut proxy_client) = tokio::io::duplex(4096); + drop(app); + let (mut proxy_upstream, mut upstream) = tokio::io::duplex(4096); + + let result = tokio::time::timeout( + std::time::Duration::from_secs(1), + relay_http_request_with_options_guarded( + &request, + &mut proxy_client, + &mut proxy_upstream, + RelayRequestOptions::default(), + ), + ) + .await + .expect("ambiguous framing must be rejected before waiting for a response"); + assert!(result.is_err()); + + drop(proxy_upstream); + let mut forwarded = Vec::new(); + upstream.read_to_end(&mut forwarded).await.unwrap(); + assert!( + !forwarded + .windows(b"GET /hidden".len()) + .any(|window| window == b"GET /hidden"), + "hidden request reached upstream: {}", + String::from_utf8_lossy(&forwarded) + ); + } + fn write_header(name: &str, value: &str, on_existing: ExistingHeaderAction) -> HeaderMutation { HeaderMutation { operation: Some(header_mutation::Operation::Write( @@ -4935,8 +4975,8 @@ mod tests { } #[tokio::test] - async fn relay_chunked_with_forwarded_prefix_and_trailers_preserves_pipeline_boundary() { - let already_forwarded = b"3\r\na"; + async fn relay_chunked_with_buffered_prefix_and_trailers_preserves_pipeline_boundary() { + let already_read = b"3\r\na"; let body_remainder = b"bc\r\n0\r\nX-Checksum: abc123\r\n\r\n"; let pipelined_request = b"DELETE /blocked HTTP/1.1\r\nHost: example.com\r\nContent-Length: 0\r\n\r\n"; @@ -4947,16 +4987,10 @@ mod tests { client_writer.write_all(&later_read).await.unwrap(); let mut relay_reader = tokio::io::BufReader::with_capacity(4096, relay_reader); let (mut relay_writer, mut upstream_reader) = tokio::io::duplex(4096); - relay_writer.write_all(already_forwarded).await.unwrap(); - relay_chunked( - &mut relay_reader, - &mut relay_writer, - already_forwarded, - None, - ) - .await - .expect("chunked body with trailers should relay"); + relay_chunked(&mut relay_reader, &mut relay_writer, already_read, None) + .await + .expect("chunked body with trailers should relay"); let mut remaining = vec![0; pipelined_request.len()]; relay_reader.read_exact(&mut remaining).await.unwrap(); @@ -4965,7 +4999,7 @@ mod tests { drop(relay_writer); let mut forwarded = Vec::new(); upstream_reader.read_to_end(&mut forwarded).await.unwrap(); - let mut expected = already_forwarded.to_vec(); + let mut expected = already_read.to_vec(); expected.extend_from_slice(body_remainder); assert_eq!(forwarded, expected); } @@ -5585,7 +5619,7 @@ mod tests { .expect_err("invalid chunk framing must surface as an error"); assert!( - err.to_string().contains("Invalid chunk size token"), + err.to_string().contains("chunk size token"), "unexpected error: {err}" ); assert!( @@ -6937,6 +6971,23 @@ mod tests { ); } + #[tokio::test] + async fn response_middleware_headers_only_does_not_forward_ambiguous_chunked_overflow() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nAAAA\r\n0\r\nA: b\n\r\nHTTP/1.1 204 No Content\r\n\r\n", + "GET", + ResponseRelayScript::HeadersOnly, + ) + .await; + + assert!(outcome.is_err()); + assert!( + !delivered + .windows(b"HTTP/1.1 204 No Content".len()) + .any(|window| window == b"HTTP/1.1 204 No Content") + ); + } + #[tokio::test] async fn response_middleware_whole_body_delays_commit_and_sets_length() { let (outcome, delivered) = run_response_middleware_relay( @@ -7328,6 +7379,8 @@ mod tests { for response in [ b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\nBad Name: value\r\n\r\n".as_slice(), b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\nContent-Length: 7\r\n\r\n".as_slice(), + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n0\n\r\n\r\n".as_slice(), + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\nA: b\n\r\nHTTP/1.1 204 No Content\r\n\r\n".as_slice(), ] { let (outcome, delivered) = run_response_middleware_relay( response, @@ -8121,6 +8174,35 @@ mod tests { ); } + #[tokio::test] + async fn relay_response_does_not_forward_ambiguous_chunked_overflow() { + let response = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nAAAA\r\n0\r\nA: b\n\r\nHTTP/1.1 204 No Content\r\n\r\n"; + let mut upstream = response.as_slice(); + let (mut client_read, mut client_write) = tokio::io::duplex(4096); + + let error = relay_response( + "GET", + &mut upstream, + &mut client_write, + RelayResponseOptions::default(), + None, + ) + .await + .expect_err("ambiguous response framing must fail closed"); + assert!(error.to_string().contains("chunk trailer"), "{error}"); + + client_write.shutdown().await.unwrap(); + let mut delivered = Vec::new(); + client_read.read_to_end(&mut delivered).await.unwrap(); + assert!( + !delivered + .windows(b"HTTP/1.1 204 No Content".len()) + .any(|window| window == b"HTTP/1.1 204 No Content"), + "hidden response reached the client: {}", + String::from_utf8_lossy(&delivered) + ); + } + #[tokio::test] async fn relay_response_normal_content_length() { let response = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"; @@ -10004,6 +10086,28 @@ mod tests { assert!(!String::from_utf8_lossy(&forwarded).contains("env%3AKEY")); } + #[tokio::test] + async fn guarded_chunked_body_rejects_bare_lf_trailer() { + let wire = + b"4\r\nAAAA\r\n0\r\nA: b\n\r\nGET /hidden HTTP/1.1\r\nHost: hidden.example\r\n\r\n"; + let mut output = Vec::new(); + let error = relay_chunked_with_marker_guard( + &mut tokio::io::empty(), + &mut output, + wire, + RelayRequestOptions::default(), + ) + .await + .expect_err("ambiguous trailer framing must fail closed"); + + assert!(error.to_string().contains("chunk trailer"), "{error}"); + assert!( + !output + .windows(b"GET /hidden".len()) + .any(|window| window == b"GET /hidden") + ); + } + #[tokio::test] async fn relay_request_body_rewrites_provider_alias_header_and_urlencoded_token() { let (_, resolver) = SecretResolver::from_provider_env( diff --git a/crates/openshell-supervisor-network/src/l7/rest/http_response.rs b/crates/openshell-supervisor-network/src/l7/rest/http_response.rs index 8b637ef263..3efc323bdb 100644 --- a/crates/openshell-supervisor-network/src/l7/rest/http_response.rs +++ b/crates/openshell-supervisor-network/src/l7/rest/http_response.rs @@ -236,20 +236,27 @@ where return Ok(RelayOutcome::Reusable); } - // Forward response headers + any overflow body bytes - client.write_all(&buf).await.into_diagnostic()?; + // Hold buffered chunk framing until relay_chunked validates it. + client + .write_all(&buf[..header_end]) + .await + .into_diagnostic()?; + let overflow = &buf[header_end..]; let overflow_len = (buf.len() - header_end) as u64; // Forward remaining response body match body_length { BodyLength::ContentLength(len) => { + if !overflow.is_empty() { + client.write_all(overflow).await.into_diagnostic()?; + } let remaining = len.saturating_sub(overflow_len); if remaining > 0 { relay_fixed(upstream, client, remaining, None).await?; } } BodyLength::Chunked => { - relay_chunked(upstream, client, &buf[header_end..], None).await?; + relay_chunked(upstream, client, overflow, None).await?; } BodyLength::None => unreachable!(), } @@ -817,9 +824,11 @@ where }); } - client.write_all(overflow).await.into_diagnostic()?; match body_length { BodyLength::ContentLength(length) => { + if !overflow.is_empty() { + client.write_all(overflow).await.into_diagnostic()?; + } let remaining = length.saturating_sub(overflow.len() as u64); if remaining > 0 { relay_fixed(upstream, client, remaining, None).await?; @@ -827,6 +836,9 @@ where } BodyLength::Chunked => relay_chunked(upstream, client, overflow, None).await?, BodyLength::None if server_wants_close || event_stream => { + if !overflow.is_empty() { + client.write_all(overflow).await.into_diagnostic()?; + } if event_stream { relay_until_eof_without_idle_timeout(upstream, client).await?; } else { @@ -835,7 +847,11 @@ where client.flush().await.into_diagnostic()?; return Ok(RelayOutcome::Consumed); } - BodyLength::None => {} + BodyLength::None => { + if !overflow.is_empty() { + client.write_all(overflow).await.into_diagnostic()?; + } + } } client.flush().await.into_diagnostic()?; Ok(if server_wants_close { @@ -1450,15 +1466,7 @@ where let mut size_line = read_response_line_with_deadline(reader, session, client, &mut framing).await?; loop { - let size_line_text = std::str::from_utf8(&size_line) - .map_err(|_| miette!("Invalid UTF-8 in response chunk-size line"))?; - let size_token = size_line_text - .split(';') - .next() - .map(str::trim) - .unwrap_or_default(); - let chunk_size = usize::from_str_radix(size_token, 16) - .map_err(|_| miette!("Invalid HTTP response chunk size"))?; + let chunk_size = parse_chunk_size_line(&size_line)?; if chunk_size == 0 { flush_normalized_response_bytes(session, client, pending, &mut framing).await?; return read_response_trailers( @@ -1894,6 +1902,7 @@ where let mut trailers = Vec::new(); loop { let line = read_response_line_with_deadline(reader, session, client, framing).await?; + validate_chunk_trailer_line(&line)?; if line.is_empty() { return Ok(trailers); } diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index fc20dca23c..90c4c67c9b 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -5108,16 +5108,7 @@ fn complete_chunked_body_prefix_len(bytes: &[u8]) -> Option { .windows(2) .position(|window| window == b"\r\n")? + pos; - let size_line = std::str::from_utf8(&bytes[pos..line_end]).ok()?; - let size = usize::from_str_radix( - size_line - .split(';') - .next() - .map(str::trim) - .unwrap_or_default(), - 16, - ) - .ok()?; + let size = crate::l7::http::parse_chunk_size_line(&bytes[pos..line_end]).ok()?; pos = line_end.checked_add(2)?; if size == 0 { @@ -5126,6 +5117,7 @@ fn complete_chunked_body_prefix_len(bytes: &[u8]) -> Option { .windows(2) .position(|window| window == b"\r\n")? + pos; + crate::l7::http::validate_chunk_trailer_line(&bytes[pos..trailer_end]).ok()?; let empty = trailer_end == pos; pos = trailer_end.checked_add(2)?; if empty { From f01a664029888ac32b007acb7e1f8ff823ba85bc Mon Sep 17 00:00:00 2001 From: Polite_realism <119441270+politerealism@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:33:38 +0000 Subject: [PATCH 16/32] docs: clarify vouch timing, DCO vs signing, and PR checklist guidance (#2810) Spell out that auto-closed PRs keep their discussion and can be reopened or replaced, separate DCO sign-off from cryptographic commit signing, and say to mark PR checklist items complete only after verification. Signed-off-by: politerealism --- CONTRIBUTING.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 77bc795197..4bc2fb5c6a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,9 +27,9 @@ We use a vouch system. This exists because AI makes it trivial to generate plaus 3. Write in your own words. AI-generated vouch requests will be denied. 4. A maintainer will comment `/vouch` if approved, and the request discussion will close automatically. -5. Once vouched, you can submit pull requests. +5. **Get vouched before opening a PR.** Once vouched, you can submit pull requests. -**If you are not vouched, any pull request you open will be automatically closed.** Org members and collaborators with push access bypass this check. +**If you are not vouched, any pull request you open will be automatically closed.** The PR and its discussion are preserved. Once vouched, open a new PR (preferred) or reopen the closed one. It cannot be held open or converted to a draft to bypass the check. Org members and collaborators with push access bypass this check. ### Finding Work @@ -490,10 +490,17 @@ By making a contribution to this project, I certify that: ``` ```bash -git commit -s -m "feat(sandbox): add new capability" +git commit --signoff --message "feat(sandbox): add new capability" ``` -DCO sign-off is separate from cryptographic commit signing. CI requires signing for org members so that copy-pr-bot can mirror your PR automatically; see [CI.md](CI.md#commit-signing) for setup. +DCO sign-off is separate from cryptographic commit signing, and the two have different requirements: + +- **DCO sign-off** (`--signoff`) adds the `Signed-off-by` trailer to each commit. It applies to every contributor. The DCO Assistant bot also asks you to sign the DCO once, by commenting on your PR. +- **Cryptographic commit signing** applies to org members and collaborators only. CI requires it so that copy-pr-bot can mirror your PR automatically; see [CI.md](CI.md#commit-signing) for setup. + +### PR testing checklist + +Only mark a checklist item as complete after you have run and verified it, not as a plan for what you intend to test. Unchecked boxes at submission are fine; they show what still needs verification before merge. ## CI From cfe9850145c8c08dee7e8f49066e6515a27a5631 Mon Sep 17 00:00:00 2001 From: Eric Curtin Date: Mon, 12 Oct 2026 20:05:05 +0000 Subject: [PATCH 17/32] fix(sandbox): name Landlock ABI in startup error (#4413) Signed-off-by: Eric Curtin --- crates/openshell-sandbox/src/main.rs | 12 +-- .../src/sandbox/linux/landlock.rs | 82 +++++++++++++------ .../src/sandbox/linux/mod.rs | 2 + docs/how-it-works/policies/schema.mdx | 7 +- 4 files changed, 67 insertions(+), 36 deletions(-) diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 0095e68e7c..3890d3a7a1 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -154,6 +154,10 @@ fn qualify_runtime() -> Result<(openshell_sandbox::RuntimeQualification, Qualifi // broker is intentionally nondumpable, so that fallback is inaccessible // even though /proc//mem remains available to mediation. let task_memory_copy = true; + // Check the ABI first: on an older kernel the allow/deny probe would fail + // with an opaque ruleset error instead of naming the ABI it needs. + let landlock_abi = + openshell_sandbox::sandbox::linux::require_landlock_abi().wrap_err("Landlock ABI probe")?; probe_landlock_allow_deny().wrap_err("Landlock allow/deny probe")?; let notification = openshell_sandbox::linux::seccomp_notify::probe_notification_api() .into_diagnostic() @@ -163,14 +167,6 @@ fn qualify_runtime() -> Result<(openshell_sandbox::RuntimeQualification, Qualifi .into_diagnostic() .wrap_err("socket loopback confinement probe")?; probe_dns_relay_bind().wrap_err("DNS relay bind probe")?; - let landlock_abi = openshell_sandbox::linux::landlock::abi_version() - .into_diagnostic() - .wrap_err("Landlock ABI probe")?; - if landlock_abi < 3 { - return Err(miette::miette!( - "sandbox self-protection requires Landlock ABI v3 or newer (including truncation), found v{landlock_abi}" - )); - } let groups = nix::unistd::getgroups() .into_diagnostic()? diff --git a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs index d8dad148af..9931321bd7 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs @@ -54,38 +54,38 @@ impl std::fmt::Display for LandlockAvailability { /// This is safe to call from the parent process and does not create any file /// descriptors or modify process state. pub fn probe_availability() -> LandlockAvailability { - // landlock_create_ruleset syscall number (same on x86_64 and aarch64). - const SYS_LANDLOCK_CREATE_RULESET: libc::c_long = 444; - // Flag: return the highest supported ABI version instead of creating a ruleset. - const LANDLOCK_CREATE_RULESET_VERSION: libc::c_uint = 1 << 0; - - // SAFETY: landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION) - // is a read-only probe that returns the ABI version or an error code. - // It does not allocate file descriptors or modify process state. - #[allow(unsafe_code)] - let ret = unsafe { - libc::syscall( - SYS_LANDLOCK_CREATE_RULESET, - std::ptr::null::(), - 0_usize, - LANDLOCK_CREATE_RULESET_VERSION, - ) - }; - - if ret >= 0 { - #[allow(clippy::cast_possible_truncation)] - LandlockAvailability::Available { abi: ret as i32 } - } else { - let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0); - match errno { + match crate::linux::landlock::abi_version() { + Ok(abi) => LandlockAvailability::Available { + abi: i32::try_from(abi).unwrap_or(i32::MAX), + }, + Err(error) => match error.raw_os_error().unwrap_or(0) { libc::ENOSYS => LandlockAvailability::NotImplemented, libc::EOPNOTSUPP => LandlockAvailability::NotEnabled, libc::EPERM => LandlockAvailability::Blocked, other => LandlockAvailability::Unknown(other), - } + }, } } +/// Lowest Landlock ABI the mandatory baseline accepts (matches [`ABI::V3`]). +/// ABI v3, introduced in Linux 6.2, adds pathname truncation. +const REQUIRED_ABI: i32 = 3; + +fn check_abi(availability: &LandlockAvailability) -> Result { + match availability { + LandlockAvailability::Available { abi } if *abi >= REQUIRED_ABI => Ok(abi.unsigned_abs()), + _ => Err(miette::miette!( + "Landlock ABI v{REQUIRED_ABI} or newer (Linux 6.2+) is required, but Landlock is {availability}" + )), + } +} + +/// Probe the kernel and fail, naming the required and detected ABI, unless it +/// supports the mandatory baseline. Returns the detected ABI. +pub fn require_abi() -> Result { + check_abi(&probe_availability()) +} + /// A prepared Landlock ruleset ready to be enforced via `restrict_self()`. /// /// Path FDs are opened before enforcement. The capability-free launch path @@ -141,6 +141,7 @@ pub fn prepare_capability_free_baseline() -> Result { fn prepare_capability_free_baseline_at(root: &Path) -> Result { // Unlike optional filesystem policy, self-protection must cover pathname // truncation as well as opens. Never silently downgrade this ABI requirement. + require_abi()?; let abi = ABI::V3; let access = AccessFs::from_all(abi); let mut ruleset = Ruleset::default() @@ -922,4 +923,35 @@ mod tests { "probe_availability Debug should produce output" ); } + + #[test] + fn check_abi_accepts_the_required_abi_or_newer() { + for abi in [REQUIRED_ABI, REQUIRED_ABI + 4] { + let detected = check_abi(&LandlockAvailability::Available { abi }).unwrap(); + assert_eq!(detected, abi.unsigned_abs()); + } + } + + #[test] + fn check_abi_names_the_required_and_detected_abi() { + let err = check_abi(&LandlockAvailability::Available { abi: 2 }) + .unwrap_err() + .to_string(); + assert!(err.contains("ABI v3 or newer"), "{err}"); + assert!(err.contains("ABI v2"), "{err}"); + } + + #[test] + fn check_abi_rejects_unavailable_landlock() { + for availability in [ + LandlockAvailability::NotImplemented, + LandlockAvailability::NotEnabled, + LandlockAvailability::Blocked, + LandlockAvailability::Unknown(libc::EIO), + ] { + let err = check_abi(&availability).unwrap_err().to_string(); + assert!(err.contains("ABI v3 or newer"), "{err}"); + assert!(err.contains(&availability.to_string()), "{err}"); + } + } } diff --git a/crates/openshell-sandbox/src/sandbox/linux/mod.rs b/crates/openshell-sandbox/src/sandbox/linux/mod.rs index a6f5817da7..ef68c81f5c 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/mod.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/mod.rs @@ -11,6 +11,8 @@ use openshell_core::policy::SandboxPolicy; use std::path::PathBuf; use std::sync::Once; +pub use landlock::require_abi as require_landlock_abi; + /// Opaque handle to a prepared-but-not-yet-enforced sandbox. /// Holds the Landlock ruleset with `PathFds` opened before child exec. pub struct PreparedSandbox { diff --git a/docs/how-it-works/policies/schema.mdx b/docs/how-it-works/policies/schema.mdx index db85e3a19d..693efb209c 100644 --- a/docs/how-it-works/policies/schema.mdx +++ b/docs/how-it-works/policies/schema.mdx @@ -77,9 +77,10 @@ fails to enforce the policy's rules: | `best_effort` | The sandbox runs without the filesystem rules and logs a high-severity finding. | | `hard_requirement` | The sandbox fails to start. | -In either mode, a sandbox requires a kernel with Landlock ABI v3 or later. The -sandbox log's `Landlock ruleset built` event reports how many paths were applied -and skipped. +In either mode, a sandbox requires a kernel with Landlock ABI v3 or later. On an +older kernel, startup fails with an error that names the required and detected +ABI. The sandbox log's `Landlock ruleset built` event reports how many paths +were applied and skipped. ## Process From 473b5f9062efc730bb68620722d16d3814ce7169 Mon Sep 17 00:00:00 2001 From: Eric Curtin Date: Mon, 12 Oct 2026 20:05:36 +0000 Subject: [PATCH 18/32] fix(server): return SQLite write versions atomically (#4408) * fix(server): return SQLite write versions atomically Use RETURNING instead of a follow-up read. Signed-off-by: Eric Curtin * refactor(server): dedupe sqlite write helpers Signed-off-by: Eric Curtin --------- Signed-off-by: Eric Curtin --- .../src/persistence/sqlite.rs | 74 ++++++++---------- .../openshell-server/src/persistence/tests.rs | 77 +++++++++++++++++++ 2 files changed, 111 insertions(+), 40 deletions(-) diff --git a/crates/openshell-server/src/persistence/sqlite.rs b/crates/openshell-server/src/persistence/sqlite.rs index ce269cd241..19cb420094 100644 --- a/crates/openshell-server/src/persistence/sqlite.rs +++ b/crates/openshell-server/src/persistence/sqlite.rs @@ -423,12 +423,12 @@ ON CONFLICT ("object_type", "workspace", "name") WHERE "name" IS NOT NULL DO UPD .await } WriteCondition::MatchResourceVersion(expected_version) => { - // Update with version check - let result = sqlx::query( + let row = sqlx::query( r#" UPDATE "objects" SET "payload" = ?4, "labels" = ?5, "updated_at_ms" = ?6, "resource_version" = "resource_version" + 1 WHERE "object_type" = ?1 AND "id" = ?2 AND "resource_version" = ?3 +RETURNING "resource_version", "created_at_ms", "updated_at_ms" "#, ) .bind(object_type) @@ -437,37 +437,28 @@ WHERE "object_type" = ?1 AND "id" = ?2 AND "resource_version" = ?3 .bind(payload) .bind(labels.unwrap_or("{}")) .bind(now_ms) - .execute(&self.pool) + .fetch_optional(&self.pool) .await .map_err(|e| map_db_error(&e))?; - if result.rows_affected() == 0 { - // The version-matched UPDATE matched no row. Distinguish a - // version mismatch (row present, different version) from an - // absent row (deleted / never existed). Both are CAS - // precondition failures, so report them as typed `Conflict` - // rather than a backend-dependent error string: absent rows - // carry `current_resource_version: None`. - let existing = self.get(object_type, id).await?; - return Err(PersistenceError::Conflict { - current_resource_version: existing.map(|record| record.resource_version), - }); + if let Some(row) = row { + return Ok(write_result_from_row(&row)); } - // Fetch the updated record to get the new resource_version - let updated = self.get(object_type, id).await?.ok_or_else(|| { - PersistenceError::Database("object disappeared after update".to_string()) - })?; - - Ok(WriteResult { - resource_version: updated.resource_version, - created_at_ms: updated.created_at_ms, - updated_at_ms: updated.updated_at_ms, + // The version-matched UPDATE matched no row. Distinguish a + // version mismatch (row present, different version) from an + // absent row (deleted / never existed). Both are CAS + // precondition failures, so report them as typed `Conflict` + // rather than a backend-dependent error string: absent rows + // carry `current_resource_version: None`. + let existing = self.get(object_type, id).await?; + Err(PersistenceError::Conflict { + current_resource_version: existing.map(|record| record.resource_version), }) } WriteCondition::Unconditional => { // Unconditional upsert by name - sqlx::query( + let row = sqlx::query( r#" INSERT INTO "objects" ("object_type", "id", "name", "workspace", "payload", "created_at_ms", "updated_at_ms", "labels", "resource_version") VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, 1) @@ -476,6 +467,7 @@ ON CONFLICT ("object_type", "workspace", "name") WHERE "name" IS NOT NULL DO UPD "updated_at_ms" = excluded."updated_at_ms", "labels" = excluded."labels", "resource_version" = "objects"."resource_version" + 1 +RETURNING "resource_version", "created_at_ms", "updated_at_ms" "#, ) .bind(object_type) @@ -485,23 +477,11 @@ ON CONFLICT ("object_type", "workspace", "name") WHERE "name" IS NOT NULL DO UPD .bind(payload) .bind(now_ms) .bind(labels.unwrap_or("{}")) - .execute(&self.pool) + .fetch_one(&self.pool) .await .map_err(|e| map_db_error(&e))?; - // Fetch the result to get the resource_version - let record = self - .get_by_name(object_type, workspace, name) - .await? - .ok_or_else(|| { - PersistenceError::Database("object disappeared after upsert".to_string()) - })?; - - Ok(WriteResult { - resource_version: record.resource_version, - created_at_ms: record.created_at_ms, - updated_at_ms: record.updated_at_ms, - }) + Ok(write_result_from_row(&row)) } } } @@ -1913,8 +1893,22 @@ pub(super) fn sqlite_sidecar_paths(path: &Path) -> [PathBuf; 2] { [with_suffix("-wal"), with_suffix("-shm")] } +/// Read the `RETURNING` columns of a write, so the result describes that write +/// and not whichever one landed last. +fn write_result_from_row(row: &sqlx::sqlite::SqliteRow) -> WriteResult { + WriteResult { + resource_version: row_resource_version(row), + created_at_ms: row.get("created_at_ms"), + updated_at_ms: row.get("updated_at_ms"), + } +} + +fn row_resource_version(row: &sqlx::sqlite::SqliteRow) -> u64 { + let resource_version: i64 = row.try_get("resource_version").unwrap_or(1); + resource_version.max(1).cast_unsigned() +} + fn row_to_object_record(row: sqlx::sqlite::SqliteRow) -> ObjectRecord { - let resource_version_i64: i64 = row.try_get("resource_version").unwrap_or(1); ObjectRecord { object_type: row.get("object_type"), id: row.get("id"), @@ -1924,7 +1918,7 @@ fn row_to_object_record(row: sqlx::sqlite::SqliteRow) -> ObjectRecord { created_at_ms: row.get("created_at_ms"), updated_at_ms: row.get("updated_at_ms"), labels: row.get("labels"), - resource_version: resource_version_i64.max(1).cast_unsigned(), + resource_version: row_resource_version(&row), } } diff --git a/crates/openshell-server/src/persistence/tests.rs b/crates/openshell-server/src/persistence/tests.rs index a62cbd2b42..7a3b435ce9 100644 --- a/crates/openshell-server/src/persistence/tests.rs +++ b/crates/openshell-server/src/persistence/tests.rs @@ -2200,6 +2200,83 @@ async fn cas_concurrent_updates_one_succeeds() { assert_eq!(record.resource_version, 2); } +/// Write `payload` to the shared `sandbox-1` row under `condition`. +async fn put_sandbox_1( + store: &Store, + payload: &str, + condition: super::WriteCondition, +) -> Result { + store + .put_if( + "sandbox", + "id-1", + "sandbox-1", + "default", + payload.as_bytes(), + None, + condition, + ) + .await +} + +/// Each write must report the version it produced, even while other writers +/// race on the same row. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn sqlite_concurrent_writes_return_their_own_resource_version() { + use super::WriteCondition; + use std::collections::BTreeSet; + use std::sync::Arc; + + const WRITERS: u64 = 8; + const WRITES: u64 = 25; + + let tmp = tempfile::tempdir().expect("tempdir"); + let url = on_disk_store_url(&tmp.path().join("openshell.db")); + let store = Arc::new(Store::connect(&url).await.expect("connect to sqlite")); + put_sandbox_1(&store, "initial", WriteCondition::MustCreate) + .await + .unwrap(); + + let writers = (0..WRITERS).map(|writer| { + let store = Arc::clone(&store); + tokio::spawn(async move { + let mut versions = Vec::new(); + for write in 0..WRITES { + let payload = format!("{writer}-{write}"); + let unconditional = put_sandbox_1(&store, &payload, WriteCondition::Unconditional) + .await + .unwrap(); + versions.push(unconditional.resource_version); + + // Retry the version-checked update until it wins a race. + loop { + let current = store.get("sandbox", "id-1").await.unwrap().unwrap(); + let condition = WriteCondition::MatchResourceVersion(current.resource_version); + match put_sandbox_1(&store, &payload, condition).await { + Ok(result) => { + versions.push(result.resource_version); + break; + } + Err(PersistenceError::Conflict { .. }) => {} + Err(err) => panic!("unexpected error: {err}"), + } + } + } + versions + }) + }); + + let mut seen = BTreeSet::new(); + for handle in futures::future::join_all(writers).await { + for version in handle.unwrap() { + assert!(seen.insert(version), "version {version} returned twice"); + } + } + + let total = WRITERS * WRITES * 2; + assert_eq!(seen, (2..=total + 1).collect::>()); +} + #[tokio::test] async fn cas_update_message_cas_succeeds() { use openshell_core::proto::Sandbox; From cc4b9c7930eb22d13ec67e7846c2c14b46acfce6 Mon Sep 17 00:00:00 2001 From: Eric Curtin Date: Mon, 12 Oct 2026 20:09:00 +0000 Subject: [PATCH 19/32] fix(cli): disable ssh connection sharing for sandbox sessions (#4157) Signed-off-by: Eric Curtin --- crates/openshell-cli/src/ssh.rs | 71 +++++++++++++++------------- crates/openshell-core/src/forward.rs | 38 +++++++++++++++ crates/openshell-tui/src/lib.rs | 35 ++------------ 3 files changed, 81 insertions(+), 63 deletions(-) diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index 30f7146d32..fd703ff77e 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -12,8 +12,8 @@ use miette::{IntoDiagnostic, Report, Result, WrapErr}; use nix::sys::signal::Signal; use openshell_core::driver_mounts; use openshell_core::forward::{ - ForwardSpec, build_proxy_command, format_gateway_url, resolve_ssh_gateway, shell_escape, - validate_ssh_session_response, write_forward_pid, + ForwardSpec, build_proxy_command, format_gateway_url, resolve_ssh_gateway, sandbox_ssh_args, + shell_escape, validate_ssh_session_response, write_forward_pid, }; use openshell_core::proto::{ CreateSshSessionRequest, GetSandboxRequest, SandboxPhase, SshRelayTarget, TcpForwardFrame, @@ -219,16 +219,7 @@ fn ssh_base_command(proxy_command: &str) -> Command { // Nested ProxyCommand processes inherit the effective verbosity too. command.env("OPENSHELL_SSH_LOG_LEVEL", &ssh_log_level); command - .arg("-o") - .arg(format!("ProxyCommand={proxy_command}")) - .arg("-o") - .arg("StrictHostKeyChecking=no") - .arg("-o") - .arg("UserKnownHostsFile=/dev/null") - .arg("-o") - .arg("GlobalKnownHostsFile=/dev/null") - .arg("-o") - .arg(format!("LogLevel={ssh_log_level}")) + .args(sandbox_ssh_args(proxy_command, &ssh_log_level)) // Detect a dead relay within ~45s. The relay rides on a TCP connection // that the client has no way to observe silently dropping (gateway // restart, supervisor restart, cluster failover), so fall back to @@ -819,16 +810,10 @@ pub async fn sandbox_forward( } /// A forward must stay owned by the process we spawn so it can be reaped or -/// tracked by PID. User SSH config may otherwise move it into a persistent mux. +/// tracked by PID. User SSH config may otherwise fork it into the background. fn ssh_forward_command(proxy_command: &str, sandbox_id: &str, spec: &ForwardSpec) -> Command { let mut command = ssh_base_command(proxy_command); command - .arg("-o") - .arg("ControlMaster=no") - .arg("-o") - .arg("ControlPath=none") - .arg("-o") - .arg("ControlPersist=no") .arg("-o") .arg("ForkAfterAuthentication=no") .arg("-N") @@ -2744,9 +2729,11 @@ mod tests { assert_eq!(forward_probe_host(&loopback), "127.0.0.1"); } + /// Resolve `ssh -G` for `command`'s arguments against a user config that + /// enables connection sharing and forking for the host `sandbox`. `extra_args` + /// supplies the host and, for the forking option, `-N`, when `command` lacks them. #[cfg(unix)] - #[test] - fn forward_ssh_command_overrides_user_multiplexing_and_forking() { + fn effective_ssh_config(command: &Command, extra_args: &[&str]) -> String { let _guard = TEST_ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -2761,16 +2748,12 @@ mod tests { ) .unwrap(); - let forward = ssh_forward_command( - "openshell ssh-proxy --sandbox demo", - "sandbox-id", - &ForwardSpec::new(18080), - ); let output = Command::new("ssh") .arg("-F") .arg(&config) .arg("-G") - .args(forward.get_args()) + .args(command.get_args()) + .args(extra_args) .output() .unwrap(); assert!( @@ -2778,18 +2761,42 @@ mod tests { "ssh -G failed: {}", String::from_utf8_lossy(&output.stderr) ); - let effective = String::from_utf8(output.stdout).unwrap(); + String::from_utf8(output.stdout).unwrap() + } + + #[cfg(unix)] + fn assert_connection_sharing_disabled(effective: &str) { assert!(effective.lines().any(|line| line == "controlmaster false")); assert!(effective.lines().any(|line| line == "controlpersist no")); assert!( - effective + !effective .lines() - .any(|line| line == "forkafterauthentication no") + .any(|line| { line.starts_with("controlpath ") && line != "controlpath none" }) + ); + } + + #[cfg(unix)] + #[test] + fn ssh_base_command_overrides_user_connection_sharing() { + let effective = + effective_ssh_config(&ssh_base_command("openshell ssh-proxy"), &["-N", "sandbox"]); + assert_connection_sharing_disabled(&effective); + } + + #[cfg(unix)] + #[test] + fn forward_ssh_command_overrides_user_multiplexing_and_forking() { + let forward = ssh_forward_command( + "openshell ssh-proxy --sandbox demo", + "sandbox-id", + &ForwardSpec::new(18080), ); + let effective = effective_ssh_config(&forward, &[]); + assert_connection_sharing_disabled(&effective); assert!( - !effective + effective .lines() - .any(|line| { line.starts_with("controlpath ") && line != "controlpath none" }) + .any(|line| line == "forkafterauthentication no") ); } diff --git a/crates/openshell-core/src/forward.rs b/crates/openshell-core/src/forward.rs index 2a7d6e89d5..10480f83a0 100644 --- a/crates/openshell-core/src/forward.rs +++ b/crates/openshell-core/src/forward.rs @@ -933,6 +933,27 @@ pub fn build_proxy_command( ) } +/// Build the `ssh` options shared by every sandbox session. +/// +/// Every sandbox is addressed as the host `sandbox`, so a user's `ControlMaster` +/// or `ControlPath` would reuse one sandbox's connection for another. Command +/// line options beat `ssh_config`, so connection sharing is turned off here. +pub fn sandbox_ssh_args(proxy_command: &str, log_level: &str) -> Vec { + [ + format!("ProxyCommand={proxy_command}"), + "StrictHostKeyChecking=no".to_string(), + "UserKnownHostsFile=/dev/null".to_string(), + "GlobalKnownHostsFile=/dev/null".to_string(), + format!("LogLevel={log_level}"), + "ControlMaster=no".to_string(), + "ControlPath=none".to_string(), + "ControlPersist=no".to_string(), + ] + .into_iter() + .flat_map(|option| ["-o".to_string(), option]) + .collect() +} + /// Error returned when a `CreateSshSessionResponse` fails validation. /// /// The response fields flow into a `ProxyCommand` string executed by @@ -1329,6 +1350,23 @@ mod tests { ); } + #[test] + fn sandbox_ssh_args_disable_connection_sharing() { + let args = sandbox_ssh_args("openshell ssh-proxy", "DEBUG"); + let options: Vec<&str> = args + .chunks(2) + .map(|pair| { + assert_eq!(pair[0], "-o"); + pair[1].as_str() + }) + .collect(); + assert!(options.contains(&"ProxyCommand=openshell ssh-proxy")); + assert!(options.contains(&"LogLevel=DEBUG")); + for option in ["ControlMaster=no", "ControlPath=none", "ControlPersist=no"] { + assert!(options.contains(&option), "missing {option}"); + } + } + /// Helper: return the concatenation of characters that appear outside /// POSIX single-quoted runs. Used by the metacharacter assertions above. fn outside_single_quotes(s: &str) -> String { diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 2b99f77add..27839d8306 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1001,16 +1001,7 @@ async fn handle_shell_connect( // Step 5: Build the SSH command. let mut command = std::process::Command::new("ssh"); command - .arg("-o") - .arg(format!("ProxyCommand={proxy_command}")) - .arg("-o") - .arg("StrictHostKeyChecking=no") - .arg("-o") - .arg("UserKnownHostsFile=/dev/null") - .arg("-o") - .arg("GlobalKnownHostsFile=/dev/null") - .arg("-o") - .arg("LogLevel=ERROR") + .args(sandbox_ssh_args(&proxy_command, "ERROR")) .arg("-tt") .arg("-o") .arg("RequestTTY=force") @@ -1139,16 +1130,7 @@ async fn handle_exec_command( // remote shell parses it correctly. let command_str = build_exec_command(command); let mut ssh = std::process::Command::new("ssh"); - ssh.arg("-o") - .arg(format!("ProxyCommand={proxy_command}")) - .arg("-o") - .arg("StrictHostKeyChecking=no") - .arg("-o") - .arg("UserKnownHostsFile=/dev/null") - .arg("-o") - .arg("GlobalKnownHostsFile=/dev/null") - .arg("-o") - .arg("LogLevel=ERROR") + ssh.args(sandbox_ssh_args(&proxy_command, "ERROR")) .arg("-tt") .arg("-o") .arg("RequestTTY=force") @@ -1209,7 +1191,7 @@ async fn handle_exec_command( // SSH utility functions are shared via openshell_core::forward. use openshell_core::forward::{ - build_proxy_command, format_gateway_url, resolve_ssh_gateway, shell_escape, + build_proxy_command, format_gateway_url, resolve_ssh_gateway, sandbox_ssh_args, shell_escape, validate_ssh_session_response, }; @@ -1606,16 +1588,7 @@ async fn start_port_forwards( let mut command = std::process::Command::new("ssh"); command - .arg("-o") - .arg(format!("ProxyCommand={proxy_command}")) - .arg("-o") - .arg("StrictHostKeyChecking=no") - .arg("-o") - .arg("UserKnownHostsFile=/dev/null") - .arg("-o") - .arg("GlobalKnownHostsFile=/dev/null") - .arg("-o") - .arg("LogLevel=ERROR") + .args(sandbox_ssh_args(&proxy_command, "ERROR")) .arg("-o") .arg("ConnectTimeout=15") .arg("-N") From 28add8924be88e6f74566eb4c62bc43e678ff994 Mon Sep 17 00:00:00 2001 From: Daneyon Hansen Date: Mon, 12 Oct 2026 20:13:15 +0000 Subject: [PATCH 20/32] fix(cli): preserve external TLS for SSH forwarding (#4344) * fix(cli): preserve external TLS for SSH forwarding Resolve the external scheme together with its authority when replacing an internal bind address with the registered gateway endpoint. Ported from #3714. Fixes: #3674 Signed-off-by: Daneyon Hansen * docs(kubernetes): explain TLS SSH relay endpoint Signed-off-by: Daneyon Hansen --------- Signed-off-by: Daneyon Hansen --- crates/openshell-cli/src/ssh.rs | 10 ++- crates/openshell-core/src/forward.rs | 126 ++++++++++++++++++--------- crates/openshell-tui/src/lib.rs | 30 +++++-- docs/kubernetes/ingress.mdx | 2 + 4 files changed, 115 insertions(+), 53 deletions(-) diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index fd703ff77e..19913bff0f 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -178,9 +178,13 @@ async fn ssh_session_config( // to 127.0.0.1 but the cluster is actually running on a remote host. #[allow(clippy::cast_possible_truncation)] let gateway_port_u16 = session.gateway_port as u16; - let (gateway_host, gateway_port) = - resolve_ssh_gateway(&session.gateway_host, gateway_port_u16, server); - format_gateway_url(&session.gateway_scheme, &gateway_host, gateway_port) + let (gateway_scheme, gateway_host, gateway_port) = resolve_ssh_gateway( + &session.gateway_scheme, + &session.gateway_host, + gateway_port_u16, + server, + ); + format_gateway_url(&gateway_scheme, &gateway_host, gateway_port) }; let gateway_name = tls .gateway_name() diff --git a/crates/openshell-core/src/forward.rs b/crates/openshell-core/src/forward.rs index 10480f83a0..c8e6c10131 100644 --- a/crates/openshell-core/src/forward.rs +++ b/crates/openshell-core/src/forward.rs @@ -826,50 +826,64 @@ fn lsof_listeners(port: u16) -> Option { // SSH utility functions (shared between CLI and TUI) // --------------------------------------------------------------------------- -/// Resolve the SSH gateway host and port for a sandbox connection. +/// Resolve the SSH gateway scheme, host, and port for a sandbox connection. /// /// If the server-provided gateway host is a loopback address, use the host /// and port from the cluster endpoint instead so the client connects to the -/// right machine. The server returns its internal bind address (e.g. 0.0.0.0:8080) -/// which may not be reachable from outside — the cluster URL has the actual -/// Docker-mapped or tunnel port. +/// right machine. The server returns its internal bind address (e.g. +/// `http://0.0.0.0:8080`) which may not be reachable from outside — the +/// cluster URL has the actual scheme and Docker-mapped or tunnel authority. pub fn resolve_ssh_gateway( + gateway_scheme: &str, gateway_host: &str, gateway_port: u16, cluster_url: &str, -) -> (String, u16) { +) -> (String, String, u16) { let is_loopback = gateway_host == "127.0.0.1" || gateway_host == "0.0.0.0" || gateway_host == "localhost" + || gateway_host == "::" || gateway_host == "::1"; if !is_loopback { - return (gateway_host.to_string(), gateway_port); + return ( + gateway_scheme.to_string(), + gateway_host.to_string(), + gateway_port, + ); } - // Extract host and port from the cluster URL. The cluster URL represents - // the externally reachable endpoint (e.g. Docker port-mapped address). + // Extract the scheme, host, and port from the cluster URL. The cluster URL + // represents the externally reachable endpoint (e.g. a TLS-terminating + // proxy or Docker port-mapped address). if let Ok(url) = url::Url::parse(cluster_url) + && matches!(url.scheme(), "http" | "https") && let Some(host) = url.host_str() { + let cluster_scheme = url.scheme().to_string(); let cluster_port = url.port_or_known_default().unwrap_or(gateway_port); let cluster_is_loopback = host == "127.0.0.1" || host == "0.0.0.0" || host == "localhost" || host == "::1"; if !cluster_is_loopback { - // Remote cluster: use the remote host but keep the cluster URL port. - return (host.to_string(), cluster_port); + // Remote cluster: use the complete externally reachable endpoint. + return (cluster_scheme, host.to_string(), cluster_port); } // Unspecified addresses are bind-only, and tonic cannot use an IPv6 // literal as a TLS DNS name. In those cases, keep the cluster URL's // already-reachable authority. Other loopback addresses retain the - // gateway-reported host. + // gateway-reported host, but use the cluster scheme and port because + // those describe the externally reachable listener. if matches!(gateway_host, "0.0.0.0" | "::" | "::1") { - return (host.to_string(), cluster_port); + return (cluster_scheme, host.to_string(), cluster_port); } - return (gateway_host.to_string(), cluster_port); + return (cluster_scheme, gateway_host.to_string(), cluster_port); } - (gateway_host.to_string(), gateway_port) + ( + gateway_scheme.to_string(), + gateway_host.to_string(), + gateway_port, + ) } /// Bracket a bare IPv6 literal (e.g. `::1` → `[::1]`) so it can be embedded in @@ -1097,51 +1111,59 @@ mod tests { #[test] fn resolve_ssh_gateway_keeps_non_loopback() { - let (host, port) = resolve_ssh_gateway("10.0.0.5", 8080, "https://spark.local"); - assert_eq!(host, "10.0.0.5"); - assert_eq!(port, 8080); + let resolved = resolve_ssh_gateway("http", "10.0.0.5", 8080, "https://spark.local"); + assert_eq!(resolved, ("http".to_string(), "10.0.0.5".to_string(), 8080)); } #[test] fn resolve_ssh_gateway_overrides_loopback_with_cluster_host() { - let (host, port) = resolve_ssh_gateway("127.0.0.1", 8080, "https://spark.local"); - assert_eq!(host, "spark.local"); - assert_eq!(port, 443); + let resolved = resolve_ssh_gateway("http", "127.0.0.1", 8080, "https://spark.local"); + assert_eq!( + resolved, + ("https".to_string(), "spark.local".to_string(), 443) + ); } #[test] fn resolve_ssh_gateway_overrides_zeros_with_cluster_host() { - let (host, port) = resolve_ssh_gateway("0.0.0.0", 8080, "https://10.0.0.5:443"); - assert_eq!(host, "10.0.0.5"); - assert_eq!(port, 443); + let resolved = resolve_ssh_gateway("http", "0.0.0.0", 8080, "https://10.0.0.5:443"); + assert_eq!(resolved, ("https".to_string(), "10.0.0.5".to_string(), 443)); } #[test] fn resolve_ssh_gateway_uses_known_default_http_port() { - let (host, port) = resolve_ssh_gateway("0.0.0.0", 8080, "http://gateway.example.test"); - assert_eq!(host, "gateway.example.test"); - assert_eq!(port, 80); + let resolved = resolve_ssh_gateway("https", "0.0.0.0", 8080, "http://gateway.example.test"); + assert_eq!( + resolved, + ("http".to_string(), "gateway.example.test".to_string(), 80) + ); } #[test] fn resolve_ssh_gateway_overrides_localhost() { - let (host, port) = resolve_ssh_gateway("localhost", 8080, "https://remote-host:443"); - assert_eq!(host, "remote-host"); - assert_eq!(port, 443); + let resolved = resolve_ssh_gateway("http", "localhost", 8080, "https://remote-host:443"); + assert_eq!( + resolved, + ("https".to_string(), "remote-host".to_string(), 443) + ); } #[test] fn resolve_ssh_gateway_no_override_when_cluster_is_also_loopback() { - let (host, port) = resolve_ssh_gateway("127.0.0.1", 8080, "https://127.0.0.1:443"); - assert_eq!(host, "127.0.0.1"); - assert_eq!(port, 443); + let resolved = resolve_ssh_gateway("http", "127.0.0.1", 8080, "https://127.0.0.1:443"); + assert_eq!( + resolved, + ("https".to_string(), "127.0.0.1".to_string(), 443) + ); } #[test] fn resolve_ssh_gateway_preserves_loopback_tls_authority() { - let (host, port) = resolve_ssh_gateway("::1", 8080, "https://localhost:8443"); - assert_eq!(host, "localhost"); - assert_eq!(port, 8443); + let resolved = resolve_ssh_gateway("http", "::1", 8080, "https://localhost:8443"); + assert_eq!( + resolved, + ("https".to_string(), "localhost".to_string(), 8443) + ); } #[test] @@ -1149,16 +1171,38 @@ mod tests { // The gateway binds 0.0.0.0 but advertises that bind address via the // SSH session response. 0.0.0.0 is not a valid connect target and is // not in any TLS cert SAN; fall through to the cluster URL's host. - let (host, port) = resolve_ssh_gateway("0.0.0.0", 8080, "https://127.0.0.1:9000"); - assert_eq!(host, "127.0.0.1"); - assert_eq!(port, 9000); + let resolved = resolve_ssh_gateway("http", "0.0.0.0", 8080, "https://127.0.0.1:9000"); + assert_eq!( + resolved, + ("https".to_string(), "127.0.0.1".to_string(), 9000) + ); + } + + #[test] + fn resolve_ssh_gateway_swaps_unspecified_ipv6_for_cluster_host() { + let resolved = resolve_ssh_gateway("http", "::", 8080, "https://localhost:18443"); + assert_eq!( + resolved, + ("https".to_string(), "localhost".to_string(), 18443) + ); } #[test] fn resolve_ssh_gateway_handles_invalid_cluster_url() { - let (host, port) = resolve_ssh_gateway("127.0.0.1", 8080, "not-a-url"); - assert_eq!(host, "127.0.0.1"); - assert_eq!(port, 8080); + let resolved = resolve_ssh_gateway("https", "127.0.0.1", 8080, "not-a-url"); + assert_eq!( + resolved, + ("https".to_string(), "127.0.0.1".to_string(), 8080) + ); + } + + #[test] + fn resolve_ssh_gateway_ignores_non_http_cluster_url() { + let resolved = resolve_ssh_gateway("https", "127.0.0.1", 8080, "ssh://remote-host:22"); + assert_eq!( + resolved, + ("https".to_string(), "127.0.0.1".to_string(), 8080) + ); } #[test] diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 27839d8306..73b745751c 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -978,9 +978,13 @@ async fn handle_shell_connect( // Step 3: Resolve gateway address (handle loopback override). #[allow(clippy::cast_possible_truncation)] let gateway_port_u16 = session.gateway_port as u16; - let (gateway_host, gateway_port) = - resolve_ssh_gateway(&session.gateway_host, gateway_port_u16, &app.endpoint); - let gateway_url = format_gateway_url(&session.gateway_scheme, &gateway_host, gateway_port); + let (gateway_scheme, gateway_host, gateway_port) = resolve_ssh_gateway( + &session.gateway_scheme, + &session.gateway_host, + gateway_port_u16, + &app.endpoint, + ); + let gateway_url = format_gateway_url(&gateway_scheme, &gateway_host, gateway_port); // Step 4: Build the ProxyCommand using our own binary. let exe = match std::env::current_exe() { @@ -1105,9 +1109,13 @@ async fn handle_exec_command( // Step 2: Resolve gateway and build ProxyCommand (same as handle_shell_connect). #[allow(clippy::cast_possible_truncation)] let gateway_port_u16 = session.gateway_port as u16; - let (gateway_host, gateway_port) = - resolve_ssh_gateway(&session.gateway_host, gateway_port_u16, &app.endpoint); - let gateway_url = format_gateway_url(&session.gateway_scheme, &gateway_host, gateway_port); + let (gateway_scheme, gateway_host, gateway_port) = resolve_ssh_gateway( + &session.gateway_scheme, + &session.gateway_host, + gateway_port_u16, + &app.endpoint, + ); + let gateway_url = format_gateway_url(&gateway_scheme, &gateway_host, gateway_port); let exe = match std::env::current_exe() { Ok(p) => p, @@ -1559,9 +1567,13 @@ async fn start_port_forwards( // Resolve gateway address. #[allow(clippy::cast_possible_truncation)] let gateway_port_u16 = session.gateway_port as u16; - let (gateway_host, gateway_port) = - resolve_ssh_gateway(&session.gateway_host, gateway_port_u16, endpoint); - let gateway_url = format_gateway_url(&session.gateway_scheme, &gateway_host, gateway_port); + let (gateway_scheme, gateway_host, gateway_port) = resolve_ssh_gateway( + &session.gateway_scheme, + &session.gateway_host, + gateway_port_u16, + endpoint, + ); + let gateway_url = format_gateway_url(&gateway_scheme, &gateway_host, gateway_port); // Build ProxyCommand. let exe = match std::env::current_exe() { diff --git a/docs/kubernetes/ingress.mdx b/docs/kubernetes/ingress.mdx index 69b18ea3f2..b17d80640c 100644 --- a/docs/kubernetes/ingress.mdx +++ b/docs/kubernetes/ingress.mdx @@ -265,6 +265,8 @@ If the release uses `workload.kind=deployment`, this upgrade replaces the Deploy Sandbox SSH uses the gateway endpoint registered with the CLI. No separate Helm SSH host or port values are required. +When a Gateway terminates TLS, register its externally reachable `https://` endpoint with the CLI. OpenShell uses that registered endpoint for `sandbox connect`, `sandbox exec`, and port forwarding, even when the gateway pod reports an internal plaintext endpoint. + ## Next Steps Return to [Setup](/kubernetes/setup) to complete the installation. From 3495c8e92af8947046f4f938f929bfb7057eb455 Mon Sep 17 00:00:00 2001 From: Yuedong Wu Date: Mon, 12 Oct 2026 20:21:27 +0000 Subject: [PATCH 21/32] fix(kubernetes): refresh sandbox runtime image on start (#4376) Signed-off-by: Yuedong Wu --- crates/openshell-driver-kubernetes/README.md | 6 +- .../openshell-driver-kubernetes/src/driver.rs | 129 +++++++++++++---- docs/kubernetes/setup.mdx | 12 ++ e2e/rust/Cargo.toml | 5 + .../tests/kubernetes_runtime_reconcile.rs | 136 ++++++++++++++++++ skills/debug-openshell-cluster/SKILL.md | 2 +- 6 files changed, 261 insertions(+), 29 deletions(-) create mode 100644 e2e/rust/tests/kubernetes_runtime_reconcile.rs diff --git a/crates/openshell-driver-kubernetes/README.md b/crates/openshell-driver-kubernetes/README.md index 29f278ff23..93a1689977 100644 --- a/crates/openshell-driver-kubernetes/README.md +++ b/crates/openshell-driver-kubernetes/README.md @@ -104,8 +104,10 @@ the anchor stays in the gateway's trust domain rather than the sandbox namespace. The TLS channel binds the namespace, Sandbox CR, workload Pod, supervisor Pod, and shared network-policy identities. Stop deletes the workload and supervisor Pods. Start rotates both Secrets and creates a new supervisor -Pod before releasing a new workload Pod. The shared network fence remains for -the lifetime of the namespace. +Pod before releasing a new workload Pod. It also renders the trusted init +container again from gateway config, so a sandbox created by an older gateway +starts with the current sandbox runtime image. The shared network fence +remains for the lifetime of the namespace. Kubernetes policies are additive, and the API does not attest that the CNI enforces them. Keep sandbox namespaces administrative: untrusted principals diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index 42f75abaa6..f637754b82 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -2314,7 +2314,7 @@ impl KubernetesComputeDriver { .as_deref() .unwrap_or_default() .iter() - .find(|container| container.name == "openshell-sandbox-bootstrap") + .find(|container| container.name == SANDBOX_BOOTSTRAP_CONTAINER_NAME) .ok_or_else(|| fail("trusted bootstrap init container missing"))?; check_container(bootstrap, "bootstrap init container")?; let mounts_volume = |container: &k8s_openapi::api::core::v1::Container, @@ -3222,6 +3222,21 @@ impl KubernetesComputeDriver { })?; let (agent_uid, agent_gid, _) = self.resolve_sandbox_identity_in_namespace(&namespace).await; + let mut init_containers = object + .data + .pointer("/spec/podTemplate/spec/initContainers") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + replace_sandbox_bootstrap_init_container( + &mut init_containers, + sandbox_bootstrap_init_container( + &self.config.sandbox_runtime_image, + self.config.sandbox_runtime_image_pull_policy, + agent_uid, + agent_gid, + ), + )?; let supervisor = pods .create( &PostParams::default(), @@ -3312,8 +3327,11 @@ impl KubernetesComputeDriver { ANNOTATION_SANDBOX_RUNTIME_GENERATION: generation.as_str(), ANNOTATION_SANDBOX_RUNTIME_SUPERVISOR_UID: supervisor_uid.clone(), }); - running_patch["spec"]["podTemplate"]["spec"] = - restart_pod_template_spec(&volumes, &image_pull_secrets); + running_patch["spec"]["podTemplate"]["spec"] = restart_pod_template_spec( + &volumes, + &init_containers, + &image_pull_secrets, + ); running_patch }, ) @@ -4924,14 +4942,37 @@ fn runtime_pod_owner(name: &str, uid: &str) -> OwnerReference { /// generation. fn restart_pod_template_spec( volumes: &[serde_json::Value], + init_containers: &[serde_json::Value], image_pull_secrets: &[String], ) -> serde_json::Value { serde_json::json!({ "volumes": volumes, + "initContainers": init_containers, "imagePullSecrets": image_pull_secret_refs(image_pull_secrets), }) } +/// Replace the stored trusted bootstrap init container in place. Entry order +/// must not change because `workspace-init` runs the binary it stages. +fn replace_sandbox_bootstrap_init_container( + init_containers: &mut [serde_json::Value], + bootstrap: serde_json::Value, +) -> Result<(), KubernetesDriverError> { + let stored = init_containers + .iter_mut() + .find(|container| { + container.get("name").and_then(serde_json::Value::as_str) + == Some(SANDBOX_BOOTSTRAP_CONTAINER_NAME) + }) + .ok_or_else(|| { + KubernetesDriverError::Precondition( + "Sandbox pod template is missing its trusted bootstrap init container".to_string(), + ) + })?; + *stored = bootstrap; + Ok(()) +} + fn sandbox_annotations(sandbox: &Sandbox) -> BTreeMap { let mut annotations = BTreeMap::new(); annotations.insert(LABEL_SANDBOX_ID.to_string(), sandbox.id.clone()); @@ -5745,6 +5786,7 @@ fn extract_image_size(message: &str) -> Option { const SANDBOX_RUNTIME_VOLUME_NAME: &str = "openshell-runtime"; const SANDBOX_STATE_VOLUME_NAME: &str = "openshell-runtime-state"; const SANDBOX_BOOTSTRAP_VOLUME_NAME: &str = "openshell-sandbox-bootstrap"; +const SANDBOX_BOOTSTRAP_CONTAINER_NAME: &str = "openshell-sandbox-bootstrap"; const SANDBOX_POD_IDENTITY_VOLUME_NAME: &str = "openshell-pod-identity"; const SANDBOX_RUNTIME_MOUNT_PATH: &str = "/.openshell/runtime"; const SANDBOX_STATE_MOUNT_PATH: &str = "/.openshell/state"; @@ -5763,6 +5805,37 @@ fn sandbox_bootstrap_secret_name(spec: &serde_json::Value) -> Option<&str> { .filter(|name| !name.is_empty()) } +/// Render the trusted init container that stages the sandbox runtime binary. +fn sandbox_bootstrap_init_container( + image: &str, + pull_policy: Option, + uid: u32, + gid: u32, +) -> serde_json::Value { + let mut bootstrap = serde_json::json!({ + "name": SANDBOX_BOOTSTRAP_CONTAINER_NAME, + "image": image, + "command": ["/openshell-sandbox", "bootstrap"], + "securityContext": { + "runAsUser": uid, + "runAsGroup": gid, + "runAsNonRoot": true, + "readOnlyRootFilesystem": true, + "allowPrivilegeEscalation": false, + "capabilities": {"drop": ["ALL"]} + }, + "volumeMounts": [ + {"name": SANDBOX_BOOTSTRAP_VOLUME_NAME, "mountPath": crate::sandbox_runtime::SANDBOX_BOOTSTRAP_INPUT_PATH, "readOnly": true}, + {"name": SANDBOX_RUNTIME_VOLUME_NAME, "mountPath": SANDBOX_RUNTIME_MOUNT_PATH}, + {"name": SANDBOX_STATE_VOLUME_NAME, "mountPath": SANDBOX_STATE_MOUNT_PATH} + ] + }); + if let Some(policy) = pull_policy { + bootstrap["imagePullPolicy"] = serde_json::json!(policy.as_kubernetes_str()); + } + bootstrap +} + /// Render the workload Pod that runs the `OpenShell` sandbox runtime. /// /// The pod receives no gateway credential or endpoint. Its non-root sandbox @@ -5882,28 +5955,12 @@ fn apply_supervisor_sandbox_runtime_boundary( .or_insert_with(|| serde_json::json!([])) .as_array_mut() .expect("pod init containers must be an array"); - let mut bootstrap = serde_json::json!({ - "name": "openshell-sandbox-bootstrap", - "image": params.sandbox_runtime_image, - "command": ["/openshell-sandbox", "bootstrap"], - "securityContext": { - "runAsUser": params.sandbox_uid, - "runAsGroup": params.sandbox_gid, - "runAsNonRoot": true, - "readOnlyRootFilesystem": true, - "allowPrivilegeEscalation": false, - "capabilities": {"drop": ["ALL"]} - }, - "volumeMounts": [ - {"name": SANDBOX_BOOTSTRAP_VOLUME_NAME, "mountPath": crate::sandbox_runtime::SANDBOX_BOOTSTRAP_INPUT_PATH, "readOnly": true}, - {"name": SANDBOX_RUNTIME_VOLUME_NAME, "mountPath": SANDBOX_RUNTIME_MOUNT_PATH}, - {"name": SANDBOX_STATE_VOLUME_NAME, "mountPath": SANDBOX_STATE_MOUNT_PATH} - ] - }); - if let Some(policy) = params.sandbox_runtime_image_pull_policy { - bootstrap["imagePullPolicy"] = serde_json::json!(policy.as_kubernetes_str()); - } - init_containers.push(bootstrap); + init_containers.push(sandbox_bootstrap_init_container( + params.sandbox_runtime_image, + params.sandbox_runtime_image_pull_policy, + params.sandbox_uid, + params.sandbox_gid, + )); let containers = spec .get_mut("containers") @@ -11541,14 +11598,34 @@ mod tests { #[test] fn restart_template_references_the_generation_image_pull_secrets() { let volumes = vec![serde_json::json!({"name": "bootstrap"})]; - let spec = restart_pod_template_spec(&volumes, &["os-pull-sandbox-1-gen7-0".to_string()]); + let init_containers = vec![serde_json::json!({"name": SANDBOX_BOOTSTRAP_CONTAINER_NAME})]; + let spec = restart_pod_template_spec( + &volumes, + &init_containers, + &["os-pull-sandbox-1-gen7-0".to_string()], + ); assert_eq!(spec["volumes"], serde_json::json!(volumes)); + assert_eq!(spec["initContainers"], serde_json::json!(init_containers)); assert_eq!( spec["imagePullSecrets"], serde_json::json!([{"name": "os-pull-sandbox-1-gen7-0"}]) ); } + #[test] + fn restart_rerenders_stored_bootstrap_init_container() { + let workspace_init = serde_json::json!({"name": WORKSPACE_INIT_CONTAINER_NAME}); + let mut init_containers = vec![ + serde_json::json!({"name": "openshell-sandbox-bootstrap", "image": "sandbox:0.1.2"}), + workspace_init.clone(), + ]; + let bootstrap = sandbox_bootstrap_init_container("sandbox:0.1.3", None, 1500, 1500); + + replace_sandbox_bootstrap_init_container(&mut init_containers, bootstrap.clone()).unwrap(); + + assert_eq!(init_containers, vec![bootstrap, workspace_init]); + } + #[tokio::test] async fn managed_generation_image_pull_secrets_are_created_without_reading_targets() { let source = serde_json::json!({ diff --git a/docs/kubernetes/setup.mdx b/docs/kubernetes/setup.mdx index 53ecd932c5..9b924c9d06 100644 --- a/docs/kubernetes/setup.mdx +++ b/docs/kubernetes/setup.mdx @@ -228,6 +228,18 @@ openshell status +## Upgrade OpenShell + +To upgrade, rerun `helm upgrade --install` with the new `--version` and the values you installed with. Running sandboxes keep their supervisor and sandbox runtime images until they stop. When a stopped sandbox starts, it takes both images from the upgraded gateway and keeps its workload image and workspace. + +If you override images, set `supervisor.image` and `sandboxRuntime.image` to the same release, or set only `global.image.tag`. An incompatible pair fails when a sandbox starts, and the supervisor log shows the cause while the start is in progress: + +```shell +kubectl -n openshell logs -l openshell.ai/sandbox-id=,openshell.ai/component=supervisor +``` + +After you correct the images, recover a sandbox whose start timed out as described in [Sandbox Lifecycle](/how-it-works/sandboxes/overview#sandbox-lifecycle). + ## Configure Chart Values The Helm chart renders `gatewayConfig` into the mounted `gateway.toml` diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 0dd0f74511..f7726e826b 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -164,6 +164,11 @@ name = "kubernetes_corporate_proxy" path = "tests/kubernetes_corporate_proxy.rs" required-features = ["e2e-kubernetes"] +[[test]] +name = "kubernetes_runtime_reconcile" +path = "tests/kubernetes_runtime_reconcile.rs" +required-features = ["e2e-kubernetes"] + [[test]] name = "credential_drivers" path = "tests/credential_drivers.rs" diff --git a/e2e/rust/tests/kubernetes_runtime_reconcile.rs b/e2e/rust/tests/kubernetes_runtime_reconcile.rs new file mode 100644 index 0000000000..ccba4da773 --- /dev/null +++ b/e2e/rust/tests/kubernetes_runtime_reconcile.rs @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e-kubernetes")] + +//! A stopped sandbox whose stored bootstrap image predates a gateway upgrade +//! starts with the gateway's configured runtime image and keeps its identity +//! and workspace. + +use std::process::Stdio; + +use openshell_e2e::harness::cli::run_cli; +use openshell_e2e::harness::sandbox::SandboxGuard; + +const BOOTSTRAP_CONTAINER: &str = "openshell-sandbox-bootstrap"; +const STALE_IMAGE: &str = "registry.invalid/openshell/sandbox:stale"; +const MARKER: &str = "openshell-runtime-reconcile"; +const MARKER_PATH: &str = "/sandbox/.openshell-runtime-reconcile"; + +fn namespace() -> String { + std::env::var("OPENSHELL_E2E_SANDBOX_NAMESPACE").unwrap_or_else(|_| "openshell".to_string()) +} + +async fn kubectl(args: &[&str]) -> String { + let mut cmd = tokio::process::Command::new("kubectl"); + if let Ok(context) = std::env::var("OPENSHELL_E2E_KUBE_CONTEXT_ACTIVE") + && !context.trim().is_empty() + { + cmd.arg("--context").arg(context); + } + let output = cmd + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .unwrap_or_else(|error| panic!("spawn kubectl {args:?}: {error}")); + assert!( + output.status.success(), + "kubectl {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).into_owned() +} + +/// Return the sandbox's Agent Sandbox resource and the index of its stored +/// bootstrap init container. +async fn sandbox_resource(name: &str) -> (serde_json::Value, usize) { + let output = kubectl(&[ + "-n", + &namespace(), + "get", + "sandboxes.agents.x-k8s.io", + "-l", + &format!("openshell.ai/sandbox-name={name}"), + "-o", + "json", + ]) + .await; + let list: serde_json::Value = serde_json::from_str(&output).expect("parse Sandbox list"); + let [resource] = list["items"].as_array().expect("Sandbox items").as_slice() else { + panic!("expected one Sandbox resource for {name}: {list}"); + }; + let index = resource["spec"]["podTemplate"]["spec"]["initContainers"] + .as_array() + .expect("stored init containers") + .iter() + .position(|container| container["name"] == BOOTSTRAP_CONTAINER) + .expect("stored bootstrap init container"); + (resource.clone(), index) +} + +fn bootstrap_image(resource: &serde_json::Value, index: usize) -> &serde_json::Value { + &resource["spec"]["podTemplate"]["spec"]["initContainers"][index]["image"] +} + +#[tokio::test] +async fn start_refreshes_stale_bootstrap_image() { + let mut sandbox = SandboxGuard::create_keep( + &["sh", "-c", "echo reconcile-ready; exec sleep infinity"], + "reconcile-ready", + ) + .await + .expect("create sandbox"); + sandbox + .exec(&[ + "sh", + "-c", + &format!("printf '%s\\n' '{MARKER}' > '{MARKER_PATH}'"), + ]) + .await + .expect("write workspace marker"); + let (output, code) = run_cli(&["sandbox", "stop", &sandbox.name]).await; + assert_eq!(code, 0, "sandbox stop failed:\n{output}"); + + // An older gateway stored its own runtime image in the resource. + let (before, index) = sandbox_resource(&sandbox.name).await; + let patch = serde_json::json!([{ + "op": "replace", + "path": format!("/spec/podTemplate/spec/initContainers/{index}/image"), + "value": STALE_IMAGE, + }]); + kubectl(&[ + "-n", + &namespace(), + "patch", + "sandboxes.agents.x-k8s.io", + before["metadata"]["name"] + .as_str() + .expect("Sandbox resource name"), + "--type=json", + "-p", + &patch.to_string(), + ]) + .await; + + let (output, code) = run_cli(&["sandbox", "start", &sandbox.name]).await; + assert_eq!(code, 0, "sandbox start failed:\n{output}"); + + let marker = sandbox + .exec(&["cat", MARKER_PATH]) + .await + .expect("read workspace marker"); + assert!( + marker.lines().any(|line| line.trim() == MARKER), + "workspace marker should survive the restart:\n{marker}" + ); + let (after, after_index) = sandbox_resource(&sandbox.name).await; + assert_eq!(after["metadata"]["uid"], before["metadata"]["uid"]); + assert_eq!( + bootstrap_image(&after, after_index), + bootstrap_image(&before, index) + ); + + sandbox.cleanup().await; +} diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index 2ed1381fe2..ad331a2bda 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -709,7 +709,7 @@ kubectl -n openshell get "${GATEWAY_DEPLOYMENT}" -o jsonpath="{.spec.template.sp helm -n openshell get values openshell | grep -E 'repository|tag|supervisorImage|workload' ``` -The gateway, sandbox, and supervisor images should use the same release tag. A stale runtime image can make sandbox behavior lag behind gateway policy or protocol changes. +The gateway, sandbox, and supervisor images should use the same release tag. A stale runtime image can make sandbox behavior lag behind gateway policy or protocol changes. Kubernetes sandboxes take the configured supervisor and runtime images each time they start, so a sandbox that kept running through an upgrade uses its previous images until its next stop and start. For vulnerability reports, record the running image digest and scan that exact artifact. The gateway includes a pinned Distroless base; the supervisor includes From 36c4c039b775622c8876f5962015037172f810d9 Mon Sep 17 00:00:00 2001 From: Fede Kamelhar <209537060+fede-kamel@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:09:23 +0000 Subject: [PATCH 22/32] docs(runtimes): show required schema version in gateway TOML examples (#3910) The runtimes page examples omit the [openshell] version = 2 table, so a snippet copied into a new gateway.toml fails preflight with missing_version. Add the table to the first example and state that the remaining examples show only the tables they change. Closes #3881 Signed-off-by: fede-kamel --- docs/how-it-works/sandboxes/runtimes.mdx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/how-it-works/sandboxes/runtimes.mdx b/docs/how-it-works/sandboxes/runtimes.mdx index 382e800e27..b577497bd7 100644 --- a/docs/how-it-works/sandboxes/runtimes.mdx +++ b/docs/how-it-works/sandboxes/runtimes.mdx @@ -28,10 +28,15 @@ the sandbox record and driver-supported persistent storage. Set `compute_driver` in the gateway TOML file. A gateway uses one driver at a time. ```toml +[openshell] +version = 2 + [openshell.gateway] compute_driver = "docker" ``` +Every gateway TOML file must start with the `[openshell]` table and `version = 2`, or `openshell-gateway config preflight` rejects it with `missing_version`. The remaining examples on this page show only the tables they change. + When `compute_driver` is unset, the gateway auto-detects Kubernetes, then Podman, then Docker. The VM driver is never auto-detected. Configure driver-specific values, such as images, endpoints, and sizing, under `[openshell.drivers.]`. See the [Gateway Configuration File](/how-it-works/gateways/configuration) reference for every option. From 6b7a00e77e6ecc1faf46cd4da602455d54750170 Mon Sep 17 00:00:00 2001 From: Eric Curtin Date: Mon, 12 Oct 2026 21:10:15 +0000 Subject: [PATCH 23/32] docs(extensibility): name extension base capabilities (#4405) Signed-off-by: Eric Curtin --- .../openshell-core/src/extension_protocol.rs | 61 ++++++++++++++++--- docs/extensibility/overview.mdx | 23 +++++++ proto/compute_driver.proto | 2 + proto/credential_driver.proto | 2 + proto/gateway_interceptor.proto | 2 + proto/supervisor_middleware.proto | 2 + 6 files changed, 84 insertions(+), 8 deletions(-) diff --git a/crates/openshell-core/src/extension_protocol.rs b/crates/openshell-core/src/extension_protocol.rs index 9c25c6cb87..aef96b46d2 100644 --- a/crates/openshell-core/src/extension_protocol.rs +++ b/crates/openshell-core/src/extension_protocol.rs @@ -64,15 +64,23 @@ pub struct NegotiatedExtension { #[derive(Debug, Error, PartialEq, Eq)] pub enum NegotiationError { #[error( - "{family} extension '{name}' did not provide protocol metadata; upgrade the extension to a version that supports OpenShell extension negotiation" + "{family} extension '{name}' did not provide protocol metadata; upgrade the extension to a version that supports OpenShell extension negotiation (expected {expected})" )] - MissingMetadata { family: &'static str, name: String }, + MissingMetadata { + family: &'static str, + name: String, + expected: String, + }, #[error( "gateway did not provide protocol metadata to {family} extension '{name}'; upgrade the gateway and extension together" )] MissingGatewayMetadata { family: &'static str, name: String }, - #[error("{family} extension '{name}' did not provide a protocol version")] - MissingProtocolVersion { family: &'static str, name: String }, + #[error("{family} extension '{name}' did not provide a protocol version (expected {expected})")] + MissingProtocolVersion { + family: &'static str, + name: String, + expected: String, + }, #[error( "{family} extension '{name}' uses unsupported protocol {remote_major}.{remote_minor}; gateway supports {local_major}.{local_minor}" )] @@ -148,6 +156,14 @@ pub fn extension_metadata( } } +/// Protocol version and base capability an extension of `family` must declare. +fn expected_contract(family: ExtensionFamily) -> String { + format!( + "protocol {PROTOCOL_MAJOR}.{PROTOCOL_MINOR} and capability {}", + family.contract_capability() + ) +} + pub fn negotiate( family: ExtensionFamily, configured_name: impl Into, @@ -156,20 +172,24 @@ pub fn negotiate( ) -> Result { let configured_name = configured_name.into(); let family_name = family.as_str(); + let expected = expected_contract(family); let extension = extension.ok_or_else(|| NegotiationError::MissingMetadata { family: family_name, name: configured_name.clone(), + expected: expected.clone(), })?; let version = extension.protocol_version.as_ref().ok_or_else(|| { NegotiationError::MissingProtocolVersion { family: family_name, name: configured_name.clone(), + expected: expected.clone(), } })?; let gateway_version = gateway.protocol_version.as_ref().ok_or_else(|| { NegotiationError::MissingProtocolVersion { family: family_name, name: "gateway".to_string(), + expected, } })?; if version.major != gateway_version.major { @@ -401,10 +421,13 @@ mod tests { #[test] fn missing_metadata_and_major_skew_are_actionable() { let (gateway, mut extension) = compatible(); - assert!(matches!( - negotiate(ExtensionFamily::Compute, "example", &gateway, None), - Err(NegotiationError::MissingMetadata { .. }) - )); + let missing = negotiate(ExtensionFamily::Compute, "example", &gateway, None).unwrap_err(); + assert!(matches!(missing, NegotiationError::MissingMetadata { .. })); + assert!( + missing + .to_string() + .contains("protocol 1.0 and capability openshell.compute.contract") + ); extension.protocol_version.as_mut().unwrap().major = 2; assert!(matches!( negotiate( @@ -417,6 +440,28 @@ mod tests { )); } + #[test] + fn missing_protocol_version_names_the_expected_contract() { + let (gateway, mut extension) = compatible(); + extension.protocol_version = None; + let error = negotiate( + ExtensionFamily::Compute, + "example", + &gateway, + Some(extension), + ) + .unwrap_err(); + assert!(matches!( + error, + NegotiationError::MissingProtocolVersion { .. } + )); + assert!( + error + .to_string() + .contains("protocol 1.0 and capability openshell.compute.contract") + ); + } + #[test] fn both_peers_require_capabilities_from_the_other() { let (mut gateway, mut extension) = compatible(); diff --git a/docs/extensibility/overview.mdx b/docs/extensibility/overview.mdx index 149df13d96..8e551aed0e 100644 --- a/docs/extensibility/overview.mdx +++ b/docs/extensibility/overview.mdx @@ -102,6 +102,29 @@ are satisfied. It rejects different major versions, missing metadata, or an unmet required capability during startup. Built-in and external service extensions follow the same compatibility checks. +Every extension returns protocol `1.0` and lists its family's base capability in +`supported_capabilities`: + +| Family | Base capability | Returned in | +|---|---|---| +| Compute driver | `openshell.compute.contract` | `GetCapabilitiesResponse.extension` | +| Credential driver | `openshell.credentials.contract` | `GetCredentialDriverCapabilitiesResponse.extension` | +| Gateway interceptor | `openshell.gateway-interceptor.contract` | `InterceptorManifest.extension` | +| Supervisor middleware | `openshell.supervisor-middleware.contract` | `MiddlewareManifest.extension` | + +For example, a supervisor middleware `Describe` response includes: + +```json +{ + "extension": { + "protocol_version": { "major": 1, "minor": 0 }, + "implementation_name": "example/content-guard", + "implementation_version": "0.1.0", + "supported_capabilities": ["openshell.supervisor-middleware.contract"] + } +} +``` + Use `openshell gateway info` to inspect the negotiated extension families, implementation versions, protocol versions, and capabilities active on a gateway. diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index f278174b5c..60bda56914 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -101,6 +101,8 @@ message GetCapabilitiesResponse { // the driver does not support rootfs tar sources. uint64 rootfs_tar_max_bytes = 11; // Compute extension protocol metadata. Required for protocol negotiation. + // Declare protocol 1.0 and list "openshell.compute.contract" in + // supported_capabilities. openshell.extension.v1.PeerMetadata extension = 12; // Versioned effective operator admission policy (v1: followed by JSON). // Gateways require an exact policy match before activating the driver. diff --git a/proto/credential_driver.proto b/proto/credential_driver.proto index 765099d71b..da0743a1f5 100644 --- a/proto/credential_driver.proto +++ b/proto/credential_driver.proto @@ -51,6 +51,8 @@ message GetCredentialDriverCapabilitiesResponse { // True when ResolveCredentials may return expiration_time values. bool supports_expires_at = 5; // Credential-driver protocol metadata. Required for protocol negotiation. + // Declare protocol 1.0 and list "openshell.credentials.contract" in + // supported_capabilities. openshell.extension.v1.PeerMetadata extension = 6; } diff --git a/proto/gateway_interceptor.proto b/proto/gateway_interceptor.proto index 1bd12d9d64..cf556198c5 100644 --- a/proto/gateway_interceptor.proto +++ b/proto/gateway_interceptor.proto @@ -111,6 +111,8 @@ message InterceptorManifest { // post-authentication consistency check. string expected_audience = 5; // Gateway-interceptor protocol metadata. Required for negotiation. + // Declare protocol 1.0 and list "openshell.gateway-interceptor.contract" in + // supported_capabilities. openshell.extension.v1.PeerMetadata extension = 6; } diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 52793d905f..6c0cc947da 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -67,6 +67,8 @@ message MiddlewareManifest { // this post-authentication consistency check. string expected_audience = 4; // Supervisor-middleware protocol metadata. Required for negotiation. + // Declare protocol 1.0 and list "openshell.supervisor-middleware.contract" + // in supported_capabilities. openshell.extension.v1.PeerMetadata extension = 5; } From fc8d0d7adad96fce06c5d207682f050635ec9404 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Mon, 12 Oct 2026 21:21:12 +0000 Subject: [PATCH 24/32] test(tmachine): add a K3s HA conformance lane with PostgreSQL and mTLS (#4081) * test(tmachine): add K3s PostgreSQL and mTLS conformance installer Signed-off-by: Matthew Grossman * fix(tmachine): reach the HA TLS gateway through its ClusterIP Drop the port-forward restart removed by the ClusterIP change, resolve the Service DNS name covered by the server certificate to the ClusterIP, bound each authenticated API wait attempt, and collect diagnostics from every gateway Pod. Signed-off-by: Matthew Grossman * ci(tmachine): run K3s PostgreSQL and mTLS conformance Add the k3s-ha-tls installer to every core conformance matrix so Branch E2E, Release Dev, Release Tag, and manual Integration Tests cover three gateway replicas sharing PostgreSQL behind mTLS. Signed-off-by: Matthew Grossman * refactor(tmachine): rename k3s-ha-tls installer to k3s-ha TLS is part of the supported HA configuration once gateway replicas share PostgreSQL, so the installer name only needs to distinguish HA from the single-replica k3s lane. Signed-off-by: Matthew Grossman * refactor(tmachine): install K3s HA in a single Helm rollout Add an optional Helm values overlay to the K3s installer so the k3s-ha installer stages PostgreSQL first and installs the three-replica TLS gateway directly, instead of installing a plaintext gateway and upgrading. Drop the redundant unauthenticated-users override and the install-time API wait, which the conformance playbook already performs. Share K3s installer inputs in the tmachine configuration. Signed-off-by: Matthew Grossman * fix(tmachine): make K3s HA staging rerunnable and wait for PostgreSQL Apply the OpenShell namespace instead of creating it so the staging play tolerates an existing namespace, and wait for the PostgreSQL rollout so gateway Pods do not start crash-looping against a missing database. Reflow and pluralize the K3s lane notes in CI.md. Signed-off-by: Matthew Grossman --------- Signed-off-by: Matthew Grossman --- .github/workflows/branch-e2e.yml | 1 + .github/workflows/integration-runner.yml | 1 + .github/workflows/integration-test.yml | 1 + .github/workflows/release-dev.yml | 1 + .github/workflows/release-tag.yml | 1 + CI.md | 16 +-- tests/ansible/playbooks/openshell-k3s-ha.yaml | 106 ++++++++++++++++++ tests/ansible/playbooks/openshell-k3s.yaml | 8 ++ .../files/collect-k3s.sh | 9 +- tests/config.nix | 23 ++-- 10 files changed, 151 insertions(+), 16 deletions(-) create mode 100644 tests/ansible/playbooks/openshell-k3s-ha.yaml diff --git a/.github/workflows/branch-e2e.yml b/.github/workflows/branch-e2e.yml index 43ad23a628..6874f6aa3c 100644 --- a/.github/workflows/branch-e2e.yml +++ b/.github/workflows/branch-e2e.yml @@ -256,6 +256,7 @@ jobs: [ {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"conformance"}, {"environment":"ubuntu-k3s","installer":"k3s","testsuite":"conformance"}, + {"environment":"ubuntu-k3s","installer":"k3s-ha","testsuite":"conformance"}, {"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"conformance"}, {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"conformance"} ] diff --git a/.github/workflows/integration-runner.yml b/.github/workflows/integration-runner.yml index 4be954eced..98d8feb301 100644 --- a/.github/workflows/integration-runner.yml +++ b/.github/workflows/integration-runner.yml @@ -26,6 +26,7 @@ on: [ {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"conformance"}, {"environment":"ubuntu-k3s","installer":"k3s","testsuite":"conformance"}, + {"environment":"ubuntu-k3s","installer":"k3s-ha","testsuite":"conformance"}, {"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"conformance"}, {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"conformance"} ] diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index fe2428a0df..9c46b27410 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -29,6 +29,7 @@ on: [ {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"conformance"}, {"environment":"ubuntu-k3s","installer":"k3s","testsuite":"conformance"}, + {"environment":"ubuntu-k3s","installer":"k3s-ha","testsuite":"conformance"}, {"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"conformance"}, {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"conformance"} ] diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index e0ef052e3c..8b00be0f27 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -127,6 +127,7 @@ jobs: [ {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"conformance"}, {"environment":"ubuntu-k3s","installer":"k3s","testsuite":"conformance"}, + {"environment":"ubuntu-k3s","installer":"k3s-ha","testsuite":"conformance"}, {"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"conformance"}, {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"conformance"} ] diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 817b34a0c4..63e009da2b 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -178,6 +178,7 @@ jobs: [ {"environment":"ubuntu-docker-rootful","installer":"deb","testsuite":"conformance"}, {"environment":"ubuntu-k3s","installer":"k3s","testsuite":"conformance"}, + {"environment":"ubuntu-k3s","installer":"k3s-ha","testsuite":"conformance"}, {"environment":"fedora-podman-rootful","installer":"rpm","testsuite":"conformance"}, {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"conformance"} ] diff --git a/CI.md b/CI.md index 9edefde745..3c90f1723e 100644 --- a/CI.md +++ b/CI.md @@ -144,16 +144,18 @@ push-triggered workflow jobs themselves. ### K3s conformance version baseline -The tmachine `ubuntu-k3s` conformance lane pins Agent Sandbox v0.5.0 as the -compatibility baseline for the v1beta1 Sandbox API. It does not track the local +The tmachine `ubuntu-k3s` conformance lanes pin Agent Sandbox v0.5.0 as the +compatibility baseline for the v1beta1 Sandbox API. They do not track the local K3s development default, currently v1.0.3. OpenShell also supports v0.4.6 through its v1alpha1 fallback, so v0.5.0 is not the overall minimum supported version. -The `ubuntu-k3s` lane registers the gateway's ClusterIP Service directly, so -recreating the Service requires reprovisioning the fixture. Before conformance, -every installer waits up to five minutes for a connected gateway; interactive -shells skip the wait. K3s failures upload diagnostics as `tmachine-diagnostics-*` -artifacts. +The `ubuntu-k3s` lanes register the gateway's ClusterIP Service directly, so +recreating the Service requires reprovisioning the fixture. The `k3s` lane runs +one plaintext gateway. The `k3s-ha` lane runs the supported HA configuration: +three gateway replicas sharing PostgreSQL, serving TLS, with the CLI connecting +over mTLS. Before conformance, every installer waits up to five minutes for a +connected gateway; interactive shells skip the wait. K3s failures upload +diagnostics as `tmachine-diagnostics-*` artifacts. ### Run only the policy advisor conformance tests diff --git a/tests/ansible/playbooks/openshell-k3s-ha.yaml b/tests/ansible/playbooks/openshell-k3s-ha.yaml new file mode 100644 index 0000000000..efcc736d49 --- /dev/null +++ b/tests/ansible/playbooks/openshell-k3s-ha.yaml @@ -0,0 +1,106 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +--- +- name: Stage PostgreSQL for the HA gateway + hosts: all + become: true + gather_facts: false + tasks: + - name: Wait for SSH + ansible.builtin.wait_for_connection: + + # The install layer boots fresh, so the K3s API may still be starting. + - name: Apply OpenShell namespace + ansible.builtin.command: + argv: [/usr/local/bin/k3s, kubectl, apply, --filename, "-"] + stdin: | + apiVersion: v1 + kind: Namespace + metadata: + name: openshell + register: k3s_namespace + until: k3s_namespace.rc == 0 + retries: 30 + delay: 2 + + - name: Apply PostgreSQL fixture + ansible.builtin.command: + argv: [/usr/local/bin/k3s, kubectl, --namespace, openshell, apply, --filename, "-"] + stdin: "{{ lookup('ansible.builtin.file', openshell_postgres_fixture) }}" + + # Gateway Pods that start before the database crash-loop with growing backoff. + - name: Wait for PostgreSQL + ansible.builtin.command: + argv: + - /usr/local/bin/k3s + - kubectl + - --namespace + - openshell + - rollout + - status + - deployment/openshell-e2e-postgres + - --timeout=300s + changed_when: false + +- import_playbook: openshell-k3s.yaml + vars: + openshell_k3s_helm_values_overlay: | + replicaCount: 3 + workload: + kind: deployment + server: + externalDbSecret: openshell-e2e-postgres-credentials + disableTls: false + +- name: Register the HA gateway over mTLS + hosts: all + become: true + gather_facts: false + tasks: + # The chart's server certificate covers the Service DNS name but not its + # ClusterIP, and the node resolver cannot reach cluster DNS. + - name: Resolve the gateway Service name to its ClusterIP + ansible.builtin.lineinfile: + path: /etc/hosts + regexp: '\sopenshell\.openshell\.svc\.cluster\.local$' + line: "{{ k3s_gateway_address.stdout.split(':')[0] }} openshell.openshell.svc.cluster.local" + + - name: Read chart client TLS Secret + ansible.builtin.command: + argv: [/usr/local/bin/k3s, kubectl, --namespace, openshell, get, secret, openshell-client-tls, --output, json] + register: k3s_client_tls + changed_when: false + no_log: true + + - name: Create guest mTLS directory + ansible.builtin.file: + path: /home/tmachine/.config/openshell/gateways/tmachine/mtls + state: directory + owner: tmachine + group: tmachine + mode: "0700" + + - name: Write guest client TLS bundle + ansible.builtin.copy: + content: "{{ (k3s_client_tls.stdout | from_json).data[item] | b64decode }}" + dest: "/home/tmachine/.config/openshell/gateways/tmachine/mtls/{{ item }}" + owner: tmachine + group: tmachine + mode: "0600" + loop: [ca.crt, tls.crt, tls.key] + no_log: true + + # The base playbook registered a plaintext endpoint; replace it. + - name: Remove baseline guest gateway registration + become: false + ansible.builtin.command: + argv: [/usr/local/bin/openshell, gateway, remove, tmachine] + + - name: Register guest gateway + ansible.builtin.include_role: + name: openshell_client + apply: + become: false + vars: + openshell_client_gateway_endpoint: "https://openshell.openshell.svc.cluster.local:{{ k3s_gateway_address.stdout.split(':')[1] }}" diff --git a/tests/ansible/playbooks/openshell-k3s.yaml b/tests/ansible/playbooks/openshell-k3s.yaml index 591262a933..e39a9f3523 100644 --- a/tests/ansible/playbooks/openshell-k3s.yaml +++ b/tests/ansible/playbooks/openshell-k3s.yaml @@ -87,6 +87,12 @@ disableTls: true telemetryEnabled: false + - name: Write OpenShell Helm values overlay + ansible.builtin.copy: + dest: /var/lib/openshell/artifacts/values-overlay.yaml + mode: "0600" + content: "{{ openshell_k3s_helm_values_overlay | default('{}') }}" + - name: Install Agent Sandbox ansible.builtin.command: argv: @@ -132,6 +138,8 @@ - --create-namespace - --values - /var/lib/openshell/artifacts/values.yaml + - --values + - /var/lib/openshell/artifacts/values-overlay.yaml - --wait - --timeout=5m environment: diff --git a/tests/ansible/roles/openshell_diagnostics/files/collect-k3s.sh b/tests/ansible/roles/openshell_diagnostics/files/collect-k3s.sh index c20334bb13..4ec06f130b 100644 --- a/tests/ansible/roles/openshell_diagnostics/files/collect-k3s.sh +++ b/tests/ansible/roles/openshell_diagnostics/files/collect-k3s.sh @@ -43,8 +43,13 @@ collect() { collect 'Endpoint readiness' /usr/local/bin/k3s kubectl --request-timeout=5s -n openshell get endpointslices \ -o 'custom-columns=NAME:.metadata.name,PORTS:.ports,ADDRESSES:.endpoints[*].addresses,CONDITIONS:.endpoints[*].conditions' collect 'Gateway events' /usr/local/bin/k3s kubectl --request-timeout=5s -n openshell get events --sort-by=.lastTimestamp - collect 'Gateway current logs' /usr/local/bin/k3s kubectl --request-timeout=5s -n openshell logs openshell-0 -c openshell-gateway --tail=300 --timestamps - collect 'Gateway previous logs' /usr/local/bin/k3s kubectl --request-timeout=5s -n openshell logs openshell-0 -c openshell-gateway --previous --tail=300 --timestamps + # Gateway Pods are named by a StatefulSet or a Deployment, depending on the installer. + mapfile -t gateway_pods < <(timeout 10s /usr/local/bin/k3s kubectl --request-timeout=5s -n openshell get pods \ + --selector app.kubernetes.io/name=openshell,app.kubernetes.io/instance=openshell --output name 2>/dev/null) + for pod in "${gateway_pods[@]}"; do + collect "Gateway current logs ($pod)" /usr/local/bin/k3s kubectl --request-timeout=5s -n openshell logs "$pod" -c openshell-gateway --tail=300 --timestamps + collect "Gateway previous logs ($pod)" /usr/local/bin/k3s kubectl --request-timeout=5s -n openshell logs "$pod" -c openshell-gateway --previous --tail=300 --timestamps + done collect 'Memory' free -m collect 'Disk' df -h / /var/lib/rancher/k3s } | sed -E \ diff --git a/tests/config.nix b/tests/config.nix index 26d27b712b..0ede4802fa 100644 --- a/tests/config.nix +++ b/tests/config.nix @@ -19,6 +19,14 @@ let OVMF = firmwarePkgs.OVMF; }; qemu = qemuPkgs.qemu.override { hostCpuOnly = true; }; + k3sInputs = { + agent_sandbox_version = "0.5.0"; + openshell_cli_binary = "../artifacts/binaries/${muslTarget}/openshell"; + openshell_gateway_image = "../artifacts/images/openshell-gateway-tmachine.tar"; + openshell_helm_chart = "../artifacts/helm/helm-chart-0.0.0.tgz"; + openshell_sandbox_image = "../artifacts/images/openshell-sandbox-tmachine.tar"; + openshell_supervisor_image = "../artifacts/images/openshell-supervisor-tmachine.tar"; + }; config = (pkgs.formats.yaml { }).generate "tmachine-config.yaml" { machines = [ { @@ -85,13 +93,14 @@ let name = "k3s"; use_galaxy = false; playbooks = [ "ansible/playbooks/openshell-k3s.yaml" ]; - inputs = { - agent_sandbox_version = "0.5.0"; - openshell_cli_binary = "../artifacts/binaries/${muslTarget}/openshell"; - openshell_gateway_image = "../artifacts/images/openshell-gateway-tmachine.tar"; - openshell_helm_chart = "../artifacts/helm/helm-chart-0.0.0.tgz"; - openshell_sandbox_image = "../artifacts/images/openshell-sandbox-tmachine.tar"; - openshell_supervisor_image = "../artifacts/images/openshell-supervisor-tmachine.tar"; + inputs = k3sInputs; + } + { + name = "k3s-ha"; + use_galaxy = false; + playbooks = [ "ansible/playbooks/openshell-k3s-ha.yaml" ]; + inputs = k3sInputs // { + openshell_postgres_fixture = "../e2e/kubernetes/postgres-fixture.yaml"; }; } { From dc967d8a335243d891b8b60471a7db1a43c20aee Mon Sep 17 00:00:00 2001 From: Jim Meyer Date: Mon, 12 Oct 2026 22:15:42 +0000 Subject: [PATCH 25/32] chore(ci): close stale issues after 14 additional days (#3985) Closes #2401 Signed-off-by: Jim Meyer --- .github/workflows/stale.yml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index e98981c89b..7a769a0208 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -19,7 +19,7 @@ jobs: stale-issue-label: state:stale days-before-issue-stale: 14 - days-before-issue-close: -1 # -1 puts this into dry-run mode. Update to 7 to enable closing. + days-before-issue-close: 14 days-before-pr-stale: -1 days-before-pr-close: -1 @@ -31,10 +31,10 @@ jobs: stale-issue-message: > This issue has had no activity for 14 days and is now marked stale. - It may be closed in 7 days if there is no further activity. + It may be closed in 14 days if there is no further activity. Comment or remove the state:stale label to keep it open. close-issue-message: > - Closing this issue after 7 days with no activity since it was marked stale. + Closing this issue after 14 days with no activity since it was marked stale. Reopen it if the work is still relevant. stale-pull-requests: runs-on: ubuntu-latest @@ -49,13 +49,10 @@ jobs: days-before-issue-stale: -1 days-before-issue-close: -1 days-before-pr-stale: 14 - days-before-pr-close: -1 # -1 puts this into dry-run mode. Update to 7 to enable closing. + days-before-pr-close: -1 operations-per-run: 300 sort-by: updated stale-pr-message: > This pull request has had no activity for 14 days and is now marked stale. - It may be closed in 7 days if there is no further activity. - close-pr-message: > - Closing this pull request after 7 days with no activity since it was marked stale. From 85c2c64798098a477b8c27e1e03d55f09439c771 Mon Sep 17 00:00:00 2001 From: Jim Meyer Date: Mon, 12 Oct 2026 22:42:33 +0000 Subject: [PATCH 26/32] chore(codeowners): remove wildcard ownership rule (#3168) Remove blanket review routing while preserving the explicit owners of the vouch list, workflows, actions, approval helpers, and SBOM check already on main. Signed-off-by: Jim Meyer --- .github/CODEOWNERS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 14f2e5ef61..2d3daeedaa 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,5 @@ -## Broad ownership — core team reviews everything -* @NVIDIA/openshell-codeowners @mrunalp @sjenning @derekwaynecarr +# No wildcard rule. The maintainer-approval workflow checks maintainer reviews. +# Explicit ownership below protects the review and CI machinery. ## Vouch list — maintainers only (bot commits bypass, but manual edits need review) .github/VOUCHED.td @NVIDIA/openshell-codeowners From 2f9ce1d06adbcc10e02e7686d94019ae45d293b3 Mon Sep 17 00:00:00 2001 From: grs Date: Mon, 12 Oct 2026 23:03:47 +0000 Subject: [PATCH 27/32] test(vm): avoid inherited lease race in restart cleanup test (#4316) Signed-off-by: Gordon Sim --- crates/openshell-driver-vm/src/preparation.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/openshell-driver-vm/src/preparation.rs b/crates/openshell-driver-vm/src/preparation.rs index 51f087e5e5..3f6d0cc9ec 100644 --- a/crates/openshell-driver-vm/src/preparation.rs +++ b/crates/openshell-driver-vm/src/preparation.rs @@ -833,19 +833,24 @@ mod tests { fn restart_reclaims_inactive_attempts_and_preserves_active_shared_and_unknown_data() { let temp = tempfile::tempdir().unwrap(); let cache = super::super::image_cache_root_dir(temp.path()); - let inactive = Attempt::create(&cache).unwrap(); - let inactive_directory = inactive.directory.clone(); + let attempts = cache.join(ATTEMPTS_DIR); + fs::create_dir_all(&attempts).unwrap(); + // Model files left by a terminated driver without opening a live lease. + // A dropped lease can remain locked briefly when a concurrent fork has + // inherited its descriptor and not yet closed it during exec. + let inactive_directory = attempts.join(format!("attempt-{:032x}", 3)); + fs::create_dir(&inactive_directory).unwrap(); fs::write(inactive_directory.join("partial.ext4"), b"unfinished").unwrap(); - drop(inactive); + fs::write(inactive_directory.with_extension("lease"), LEASE_MARKER).unwrap(); let active = Attempt::create(&cache).unwrap(); let committed = cache.join("shared-image"); fs::create_dir(&committed).unwrap(); fs::write(committed.join("rootfs.ext4"), b"valid shared image").unwrap(); let legacy = cache.join("image.staging-legacy"); fs::create_dir(&legacy).unwrap(); - let unmarked = cache.join(ATTEMPTS_DIR).join(format!("attempt-{:032x}", 1)); + let unmarked = attempts.join(format!("attempt-{:032x}", 1)); fs::create_dir(&unmarked).unwrap(); - let link = cache.join(ATTEMPTS_DIR).join(format!("attempt-{:032x}", 2)); + let link = attempts.join(format!("attempt-{:032x}", 2)); symlink(&committed, &link).unwrap(); fs::write(unmarked.with_extension("lease"), b"unrelated lock").unwrap(); From ef863ec7e3439301a87fa1e767d1c99a0d6f5085 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Mon, 12 Oct 2026 23:09:38 +0000 Subject: [PATCH 28/32] fix(windows): restore MSVC lint for CLI and MXC (#4194) * fix(driver-mxc): pass gateway name to mocked backend Refs #4191 Signed-off-by: Philippe Martin * fix(windows): use native executable paths in network test fixtures Signed-off-by: Philippe Martin --------- Signed-off-by: Philippe Martin --- crates/openshell-driver-mxc/src/driver.rs | 2 +- .../src/identity.rs | 40 +++++--- .../openshell-supervisor-network/src/lib.rs | 2 +- .../openshell-supervisor-network/src/proxy.rs | 94 ++++++++++++------- .../src/test_support.rs | 41 +++++++- 5 files changed, 131 insertions(+), 48 deletions(-) diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 84c36945b3..c7a966a32b 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -455,7 +455,7 @@ impl MxcComputeBackend { /// Test-only constructor wiring the in-process mock `wxc-exec` shim. #[cfg(test)] pub(crate) fn new_mocked(config: MxcComputeConfig) -> Self { - let mut backend = Self::new(config); + let mut backend = Self::new(openshell_core::config::DEFAULT_GATEWAY_NAME, config); backend.invoker = WxcExecInvoker::mocked(&backend.config.wxc_exec_path); backend } diff --git a/crates/openshell-supervisor-network/src/identity.rs b/crates/openshell-supervisor-network/src/identity.rs index 5fb123a1c0..d51c0490fc 100644 --- a/crates/openshell-supervisor-network/src/identity.rs +++ b/crates/openshell-supervisor-network/src/identity.rs @@ -283,6 +283,7 @@ impl BinaryIdentityCache { mod tests { use super::*; use crate::procfs; + use crate::test_support::executable_path; use openshell_isolation_interface::contract::{ BinaryIdentity, ExecutableIdentity, Sha256Digest, }; @@ -294,19 +295,19 @@ mod tests { } fn supplied_identity( - executable_path: &str, + path: &str, executable_digest: Option<&str>, ancestors: &[(&str, Option<&str>)], ) -> BinaryIdentity { BinaryIdentity { executable: ExecutableIdentity { - path: PathBuf::from(executable_path), + path: executable_path(path), digest: executable_digest.map(digest), }, ancestors: ancestors .iter() .map(|(path, digest_value)| ExecutableIdentity { - path: PathBuf::from(path), + path: executable_path(path), digest: digest_value.map(digest), }) .collect(), @@ -383,9 +384,22 @@ mod tests { #[test] fn supplied_identity_rejects_non_absolute_or_empty_paths() { let cache = BinaryIdentityCache::new(); - for path in ["", "sandbox/tool"] { + #[cfg(not(target_os = "windows"))] + let paths = ["", "sandbox/tool"]; + #[cfg(target_os = "windows")] + let paths = [ + "", + "sandbox/tool", + "C:sandbox/tool", + "/sandbox/tool", + r"\sandbox\tool", + ]; + for path in paths { + let mut identity = supplied_identity(path, Some("11"), &[]); + // Exercise the raw invalid path, without adapting the fixture. + identity.executable.path = PathBuf::from(path); let error = cache - .verify_or_cache_supplied_identity(&supplied_identity(path, Some("11"), &[])) + .verify_or_cache_supplied_identity(&identity) .unwrap_err() .to_string(); assert!(error.contains("must be absolute")); @@ -450,7 +464,7 @@ mod tests { .hashes .lock() .unwrap() - .contains_key(Path::new("/sandbox/other")) + .contains_key(&executable_path("/sandbox/other")) ); } @@ -480,8 +494,8 @@ mod tests { assert!(error.contains("capacity")); let hashes = cache.hashes.lock().unwrap(); assert_eq!(hashes.len(), 4095); - assert!(!hashes.contains_key(Path::new("/sandbox/new-leaf"))); - assert!(!hashes.contains_key(Path::new("/sandbox/new-ancestor"))); + assert!(!hashes.contains_key(&executable_path("/sandbox/new-leaf"))); + assert!(!hashes.contains_key(&executable_path("/sandbox/new-ancestor"))); } #[test] @@ -491,7 +505,7 @@ mod tests { for index in 0..4096 { cache .verify_or_cache_with_paths( - &PathBuf::from(format!("/sandbox/pinned-{index}")), + &executable_path(&format!("/sandbox/pinned-{index}")), executable.path(), |_| Ok("11".repeat(32)), ) @@ -499,9 +513,11 @@ mod tests { } let error = cache - .verify_or_cache_with_paths(Path::new("/sandbox/overflow"), executable.path(), |_| { - Ok("11".repeat(32)) - }) + .verify_or_cache_with_paths( + &executable_path("/sandbox/overflow"), + executable.path(), + |_| Ok("11".repeat(32)), + ) .unwrap_err() .to_string(); diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index d0377b5d27..d9fcd004b6 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -33,5 +33,5 @@ pub(crate) use openshell_driver_vm::allocation_tracking as test_alloc; #[global_allocator] static TEST_ALLOCATOR: openshell_driver_vm::allocation_tracking::CountingAllocator = openshell_driver_vm::allocation_tracking::CountingAllocator; -#[cfg(all(test, target_os = "linux"))] +#[cfg(test)] mod test_support; diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 90c4c67c9b..7cf630e420 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -6990,6 +6990,7 @@ fn is_benign_relay_error(err: &miette::Report) -> bool { )] mod tests { use super::*; + use crate::test_support::{executable_path, policy_with_native_executable_paths}; use openshell_core::proposals::AgentProposals; use std::collections::HashMap as TestHashMap; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; @@ -7001,7 +7002,8 @@ mod tests { fn supplied_identity_preserves_authorized_endpoint_metadata() { let engine = OpaEngine::from_strings( include_str!("../data/sandbox-policy.rego"), - r#" + &policy_with_native_executable_paths( + r#" network_policies: inspected: name: inspected @@ -7026,11 +7028,12 @@ process: run_as_user: sandbox run_as_group: sandbox "#, + ), ) .expect("load policy"); let identity = Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from("/usr/bin/python3"), + path: executable_path("/usr/bin/python3"), digest: Some("00".repeat(32).parse().expect("digest")), }, ancestors: Vec::new(), @@ -7058,7 +7061,8 @@ process: fn supplied_identity_rejects_same_path_replacement() { let engine = OpaEngine::from_strings( include_str!("../data/sandbox-policy.rego"), - r#" + &policy_with_native_executable_paths( + r#" network_policies: credentialed: name: credentialed @@ -7081,12 +7085,13 @@ process: run_as_user: sandbox run_as_group: sandbox "#, + ), ) .expect("load policy"); let identity = |digest_byte: &str| { Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from("/sandbox/bin/client"), + path: executable_path("/sandbox/bin/client"), digest: Some(digest_byte.repeat(32).parse().expect("digest")), }, ancestors: Vec::new(), @@ -7115,7 +7120,8 @@ process: fn supplied_identity_rejects_replaced_authorizing_ancestor() { let engine = OpaEngine::from_strings( include_str!("../data/sandbox-policy.rego"), - r#" + &policy_with_native_executable_paths( + r#" network_policies: allowed: name: allowed @@ -7134,16 +7140,17 @@ process: run_as_user: sandbox run_as_group: sandbox "#, + ), ) .expect("load policy"); let identity = |ancestor_digest: &str| { Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from("/sandbox/bin/client"), + path: executable_path("/sandbox/bin/client"), digest: Some("11".repeat(32).parse().expect("digest")), }, ancestors: vec![ContractExecutableIdentity { - path: PathBuf::from("/sandbox/bin/launcher"), + path: executable_path("/sandbox/bin/launcher"), digest: Some(ancestor_digest.repeat(32).parse().expect("digest")), }], cmdline_paths: Vec::new(), @@ -7186,11 +7193,13 @@ process: run_as_group: sandbox "#; let rego = include_str!("../data/sandbox-policy.rego"); - let engine = OpaEngine::from_strings(rego, POLICY_DATA).expect("load policy"); + let engine = + OpaEngine::from_strings(rego, &policy_with_native_executable_paths(POLICY_DATA)) + .expect("load policy"); let identity = |digest_byte: &str| { Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from("/sandbox/bin/client"), + path: executable_path("/sandbox/bin/client"), digest: Some(digest_byte.repeat(32).parse().expect("digest")), }, ancestors: Vec::new(), @@ -7204,7 +7213,9 @@ process: authorize_supplied_identity(&engine, &identity_cache, intent(), &identity("11")); assert!(matches!(original.action, NetworkAction::Allow { .. })); - engine.reload(rego, POLICY_DATA).expect("reload policy"); + engine + .reload(rego, &policy_with_native_executable_paths(POLICY_DATA)) + .expect("reload policy"); let replacement = authorize_supplied_identity(&engine, &identity_cache, intent(), &identity("22")); @@ -7220,7 +7231,8 @@ process: fn supplied_identity_rejects_missing_ancestor_digest_before_policy() { let engine = OpaEngine::from_strings( include_str!("../data/sandbox-policy.rego"), - r#" + &policy_with_native_executable_paths( + r#" network_policies: allowed: name: allowed @@ -7239,15 +7251,16 @@ process: run_as_user: sandbox run_as_group: sandbox "#, + ), ) .expect("load policy"); let identity = Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from("/sandbox/bin/client"), + path: executable_path("/sandbox/bin/client"), digest: Some("11".repeat(32).parse().expect("digest")), }, ancestors: vec![ContractExecutableIdentity { - path: PathBuf::from("/sandbox/bin/launcher"), + path: executable_path("/sandbox/bin/launcher"), digest: None, }], cmdline_paths: Vec::new(), @@ -7271,7 +7284,8 @@ process: async fn staged_transparent_open_waits_for_l4_policy() { let engine = OpaEngine::from_strings( include_str!("../data/sandbox-policy.rego"), - r#" + &policy_with_native_executable_paths( + r#" network_policies: allowed: name: allowed @@ -7292,6 +7306,7 @@ process: run_as_user: sandbox run_as_group: sandbox "#, + ), ) .unwrap(); let identity_cache = BinaryIdentityCache::new(); @@ -7299,7 +7314,7 @@ process: let identity = || { Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from("/usr/bin/curl"), + path: executable_path("/usr/bin/curl"), digest: Some("00".repeat(32).parse().unwrap()), }, ancestors: Vec::new(), @@ -7392,7 +7407,10 @@ process: .expect("policy denial is sent to mapper"); assert_eq!(event.host, "203.0.113.8"); assert_eq!(event.port, 443); - assert_eq!(event.binary, "/usr/bin/curl"); + assert_eq!( + event.binary, + executable_path("/usr/bin/curl").to_str().unwrap() + ); assert_eq!(event.denial_stage, "transparent_tcp_connect"); assert!(denial_rx.try_recv().is_err(), "exactly one mapper event"); } @@ -7494,7 +7512,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } stream: Box::new(stream), binary_identity: Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from("/usr/bin/curl"), + path: executable_path("/usr/bin/curl"), digest: Some("00".repeat(32).parse().unwrap()), }, ancestors: Vec::new(), @@ -7680,7 +7698,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } async fn staged_transparent_open_dials_only_pinned_policy_dns_addresses() { let engine = OpaEngine::from_strings( include_str!("../data/sandbox-policy.rego"), - POLICY_DNS_OPEN_POLICY, + &policy_with_native_executable_paths(POLICY_DNS_OPEN_POLICY), ) .unwrap(); let store = policy_dns_test_store(); @@ -7720,7 +7738,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } async fn staged_transparent_open_proposes_a_denied_observation_hostname() { let engine = OpaEngine::from_strings( include_str!("../data/sandbox-policy.rego"), - POLICY_DNS_OPEN_POLICY, + &policy_with_native_executable_paths(POLICY_DNS_OPEN_POLICY), ) .unwrap(); let store = policy_dns_test_store(); @@ -7753,14 +7771,17 @@ process: { run_as_user: sandbox, run_as_group: sandbox } let event = denial_rx.try_recv().expect("denial is sent to the mapper"); assert_eq!(event.host, "unknown.example"); assert_eq!(event.port, 443); - assert_eq!(event.binary, "/usr/bin/curl"); + assert_eq!( + event.binary, + executable_path("/usr/bin/curl").to_str().unwrap() + ); } #[tokio::test] async fn staged_transparent_open_never_relays_an_observation_that_policy_allows() { let engine = OpaEngine::from_strings( include_str!("../data/sandbox-policy.rego"), - POLICY_DNS_OPEN_POLICY, + &policy_with_native_executable_paths(POLICY_DNS_OPEN_POLICY), ) .unwrap(); let identity_cache = BinaryIdentityCache::new(); @@ -7826,7 +7847,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } fn pinned_plan_requires_the_mapping_of_the_deciding_generation() { let engine = OpaEngine::from_strings( include_str!("../data/sandbox-policy.rego"), - POLICY_DNS_OPEN_POLICY, + &policy_with_native_executable_paths(POLICY_DNS_OPEN_POLICY), ) .unwrap(); let store = policy_dns_test_store(); @@ -7869,7 +7890,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } engine .reload( include_str!("../data/sandbox-policy.rego"), - POLICY_DNS_OPEN_POLICY, + &policy_with_native_executable_paths(POLICY_DNS_OPEN_POLICY), ) .unwrap(); let reloaded = decide("db.example", 5432); @@ -7892,7 +7913,10 @@ process: { run_as_user: sandbox, run_as_group: sandbox } ); assert_eq!( mapped.format_shorthand(), - "NET:OPEN [MED] DENIED /usr/bin/curl(0) -> blocked.invalid:80 [reason:transparent_tcp_policy_denied]" + format!( + "NET:OPEN [MED] DENIED {}(0) -> blocked.invalid:80 [reason:transparent_tcp_policy_denied]", + executable_path("/usr/bin/curl").display() + ) ); assert_eq!( serde_json::to_value(mapped).unwrap()["dst_endpoint"], @@ -7924,7 +7948,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } let cache = Arc::new(BinaryIdentityCache::new()); let identity = ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from("/usr/bin/bash"), + path: executable_path("/usr/bin/bash"), digest: Some("44".repeat(32).parse().unwrap()), }, ancestors: Vec::new(), @@ -8036,7 +8060,7 @@ process: { run_as_user: sandbox, run_as_group: sandbox } let engine = Arc::new( OpaEngine::from_strings( include_str!("../data/sandbox-policy.rego"), - &format!( + &policy_with_native_executable_paths(&format!( r#" network_policies: allowed: @@ -8049,7 +8073,7 @@ network_policies: - path: /usr/bin/curl "#, port = destination.port() - ), + )), ) .unwrap(), ); @@ -8060,7 +8084,7 @@ network_policies: stream: Box::new(stream), binary_identity: Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from("/usr/bin/curl"), + path: executable_path("/usr/bin/curl"), digest: Some("44".repeat(32).parse().unwrap()), }, ancestors: Vec::new(), @@ -8171,7 +8195,8 @@ network_policies: async fn staged_transparent_open_reports_invalid_identity_as_unavailable() { let engine = OpaEngine::from_strings( include_str!("../data/sandbox-policy.rego"), - r#" + &policy_with_native_executable_paths( + r#" network_policies: allowed: name: allowed @@ -8190,6 +8215,7 @@ process: run_as_user: sandbox run_as_group: sandbox "#, + ), ) .unwrap(); let identity_cache = BinaryIdentityCache::new(); @@ -8199,7 +8225,7 @@ process: stream: Box::new(stream), binary_identity: Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from("/usr/bin/curl"), + path: executable_path("/usr/bin/curl"), digest: None, }, ancestors: Vec::new(), @@ -8240,7 +8266,8 @@ process: async fn staged_transparent_open_reports_identity_cache_capacity_exhaustion() { let engine = OpaEngine::from_strings( include_str!("../data/sandbox-policy.rego"), - r#" + &policy_with_native_executable_paths( + r#" network_policies: allowed: name: allowed @@ -8259,6 +8286,7 @@ process: run_as_user: sandbox run_as_group: sandbox "#, + ), ) .unwrap(); let identity_cache = BinaryIdentityCache::new(); @@ -8266,7 +8294,7 @@ process: identity_cache .verify_or_cache_supplied_identity(&ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from(format!("/sandbox/pinned-{index}")), + path: executable_path(&format!("/sandbox/pinned-{index}")), digest: Some("11".repeat(32).parse().unwrap()), }, ancestors: Vec::new(), @@ -8280,7 +8308,7 @@ process: stream: Box::new(stream), binary_identity: Ok(ContractBinaryIdentity { executable: ContractExecutableIdentity { - path: PathBuf::from("/sandbox/overflow"), + path: executable_path("/sandbox/overflow"), digest: Some("22".repeat(32).parse().unwrap()), }, ancestors: Vec::new(), diff --git a/crates/openshell-supervisor-network/src/test_support.rs b/crates/openshell-supervisor-network/src/test_support.rs index 2fe79e3eb5..d1b23b6f31 100644 --- a/crates/openshell-supervisor-network/src/test_support.rs +++ b/crates/openshell-supervisor-network/src/test_support.rs @@ -1,14 +1,19 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Socket-owner subprocess fixtures. +//! Executable identity and socket-owner subprocess fixtures. +#[cfg(target_os = "linux")] use std::net::TcpStream; +#[cfg(target_os = "linux")] use std::os::fd::OwnedFd; +#[cfg(target_os = "linux")] use std::process::{Child, Command, Stdio}; +#[cfg(target_os = "linux")] pub struct SocketHolder(pub Child); +#[cfg(target_os = "linux")] impl Drop for SocketHolder { fn drop(&mut self) { let _ = self.0.kill(); @@ -18,6 +23,7 @@ impl Drop for SocketHolder { /// Keep a duplicate of the parent's connected socket in a descendant process. /// Stdin transfers descriptor ownership through the standard process API. +#[cfg(target_os = "linux")] pub fn spawn_socket_holder(stream: &TcpStream, exec_sleep: bool) -> SocketHolder { let mut command = if exec_sleep { let mut command = Command::new("/bin/sleep"); @@ -45,9 +51,42 @@ pub fn spawn_socket_holder(stream: &TcpStream, exec_sleep: bool) -> SocketHolder ) } +#[cfg(target_os = "linux")] #[test] fn socket_holder_child() { if std::env::var_os("OPENSHELL_SOCKET_HOLDER_CHILD").is_some() { std::thread::sleep(std::time::Duration::from_secs(30)); } } + +use std::path::PathBuf; + +/// Give rooted synthetic executable paths a drive on Windows. Preserve empty, +/// relative, and already native paths so invalid-identity tests remain useful. +pub fn executable_path(path: &str) -> PathBuf { + #[cfg(target_os = "windows")] + if path.starts_with('/') { + return PathBuf::from(format!("C:{path}")); + } + PathBuf::from(path) +} + +/// Match policy binary paths to the synthetic identities without changing HTTP +/// rule paths or other policy fields. +pub fn policy_with_native_executable_paths(data: &str) -> String { + let mut policy: serde_yml::Value = serde_yml::from_str(data).expect("parse test policy"); + if let Some(policies) = policy["network_policies"].as_mapping_mut() { + for (_, policy) in policies { + if let Some(binaries) = policy["binaries"].as_sequence_mut() { + for binary in binaries { + if let Some(path) = binary["path"].as_str() { + binary["path"] = serde_yml::Value::String( + executable_path(path).to_str().unwrap().to_string(), + ); + } + } + } + } + } + serde_yml::to_string(&policy).expect("serialize test policy") +} From 01c249f225ef43346c9606369974819553b334c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:11:36 +0000 Subject: [PATCH 29/32] chore(deps): bump astral-sh/setup-uv from 10.0.0 to 10.0.1 (#2767) Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 10.0.0 to 10.0.1. - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d...20cfd1bf945f4377ade1205e4dbc17946fc9a30d) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/sync-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml index 63c4a6b8b8..f5e6303dd3 100644 --- a/.github/workflows/sync-docs.yml +++ b/.github/workflows/sync-docs.yml @@ -161,7 +161,7 @@ jobs: path: docs-website - name: Install uv - uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: version: "0.10.12" python-version: "3.13" From fe316067dc2503062235106b886c206e9d8ca079 Mon Sep 17 00:00:00 2001 From: Artem Lytvyn Date: Mon, 12 Oct 2026 23:18:31 +0000 Subject: [PATCH 30/32] feat(sdk-ts): add watchLogs with loss-aware resume (#3778) Signed-off-by: letv1nnn --- docs/sdk/typescript.mdx | 19 ++ sdk/typescript/src/client.test.ts | 385 +++++++++++++++++++++++++++++- sdk/typescript/src/client.ts | 267 ++++++++++++++++++++- sdk/typescript/src/errors.test.ts | 1 + sdk/typescript/src/errors.ts | 3 + sdk/typescript/src/index.ts | 7 + 6 files changed, 679 insertions(+), 3 deletions(-) diff --git a/docs/sdk/typescript.mdx b/docs/sdk/typescript.mdx index 53d30312ee..d0c9f486bf 100644 --- a/docs/sdk/typescript.mdx +++ b/docs/sdk/typescript.mdx @@ -138,6 +138,25 @@ SSH sessions, sandbox provider attachment, configuration, and policy. Close operation-scoped streams and forwarding handles when finished. The root client does not retain a dedicated session and has no `close()` method. +## Watch Logs and Events + +`watchLogs` streams a sandbox's log lines and platform events with automatic +reconnect and loss-aware resume. Pass a previous item's `cursor` back in as +`resumeAfterCursor` to continue after a restart without gaps or duplicates: + +```ts +for await (const item of client.sandbox.watchLogs(sandbox.name, { followLogs: true })) { + if (item.kind === 'log') { + console.log(`${item.line.level} ${item.line.message}`) + } else if (item.kind === 'warning') { + console.warn(`gap: ${item.message}`) + } +} +``` + +See [Accessing Logs](/observability/accessing-logs) for the cursor and resume +contract this method implements. + ## Use the Raw Client Use `client.raw` for RPCs that the curated clients do not yet wrap. Import diff --git a/sdk/typescript/src/client.test.ts b/sdk/typescript/src/client.test.ts index 52c73c3341..4f8e4ce4b0 100644 --- a/sdk/typescript/src/client.test.ts +++ b/sdk/typescript/src/client.test.ts @@ -10,8 +10,9 @@ import * as net from 'node:net'; import type { MessageInitShape } from '@bufbuild/protobuf'; import { Code, ConnectError, createRouterTransport, type ServiceImpl, type Transport } from '@connectrpc/connect'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { + __setWatchLogsBackoffSleepForTests, errorCode, Pager, PHASE_NAMES, @@ -22,6 +23,7 @@ import { SCOPE_NAMES, ServiceAuthorizationMode, STATUS_NAMES, + type WatchEvent, } from './client.js'; import { OpenShell, @@ -315,6 +317,387 @@ describe('exec / execStream', () => { }); }); +describe('watchLogs', () => { + afterEach(() => { + // Restore the real timer-based backoff sleep for any test that stubbed it. + __setWatchLogsBackoffSleepForTests(); + }); + + it('yields log, event, and warning items with curated field mapping', async () => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + // eslint-disable-next-line require-yield + watchSandbox: async function* () { + yield { + payload: { + case: 'log', + value: { sandboxId: 'sb-id-1', level: 'INFO', target: 't', message: 'hello', source: '', fields: {} }, + }, + cursor: 'c1', + }; + yield { + payload: { + case: 'event', + value: { source: 'kubernetes', type: 'Normal', reason: 'Started', message: 'started', metadata: {} }, + }, + cursor: 'c2', + }; + yield { payload: { case: 'warning', value: { message: 'lagged' } }, cursor: '' }; + }, + }); + + const events: WatchEvent[] = []; + for await (const event of sandbox.watchLogs('sb')) events.push(event); + + expect(events).toEqual([ + { + kind: 'log', + cursor: 'c1', + line: { + sandboxId: 'sb-id-1', + timestampMs: 0, + level: 'INFO', + target: 't', + message: 'hello', + source: 'gateway', + fields: {}, + }, + }, + { + kind: 'event', + cursor: 'c2', + event: { + timestampMs: 0, + source: 'kubernetes', + type: 'Normal', + reason: 'Started', + message: 'started', + metadata: {}, + }, + }, + { kind: 'warning', message: 'lagged' }, + ]); + }); + + it('passes through fields and metadata maps unmodified', async () => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + // eslint-disable-next-line require-yield + watchSandbox: async function* () { + yield { + payload: { + case: 'log', + value: { + sandboxId: 'sb', + level: 'INFO', + target: 't', + message: 'm', + source: 'sandbox', + fields: { dst_host: 'example.com' }, + }, + }, + cursor: 'c1', + }; + yield { + payload: { + case: 'event', + value: { + source: 'docker', + type: 'Normal', + reason: 'Pulled', + message: 'pulled', + metadata: { image: 'nginx' }, + }, + }, + cursor: 'c2', + }; + }, + }); + + const events: WatchEvent[] = []; + for await (const event of sandbox.watchLogs('sb')) events.push(event); + + expect(events[0]).toMatchObject({ kind: 'log', line: { fields: { dst_host: 'example.com' } } }); + expect(events[1]).toMatchObject({ kind: 'event', event: { metadata: { image: 'nginx' } } }); + }); + + it('completes without error when the stream ends with no events', async () => { + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + // eslint-disable-next-line require-yield + watchSandbox: async function* () {}, + }); + + const events: WatchEvent[] = []; + await expect( + (async () => { + for await (const event of sandbox.watchLogs('sb')) events.push(event); + })(), + ).resolves.toBeUndefined(); + expect(events).toEqual([]); + }); + + it('defaults to followLogs when neither followLogs nor followEvents is set', async () => { + let req: { followLogs?: boolean; followEvents?: boolean } = {}; + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + // biome-ignore lint/correctness/useYield: mock only captures the request, no events to yield + watchSandbox: async function* (r) { + req = r; + }, + }); + + for await (const _event of sandbox.watchLogs('sb')) { + // drain + } + expect(req.followLogs).toBe(true); + expect(req.followEvents).toBe(false); + }); + + it('defaults to followLogs when followLogs is explicitly false and followEvents is unset', async () => { + // Regression: an earlier `??`-based default only checked whether + // followLogs was unset, not whether both flags resolved false, so an + // explicit `followLogs: false` alone silently produced a stream with + // both flags false — the exact hang the default exists to prevent. + let req: { followLogs?: boolean; followEvents?: boolean } = {}; + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + // biome-ignore lint/correctness/useYield: mock only captures the request, no events to yield + watchSandbox: async function* (r) { + req = r; + }, + }); + + for await (const _event of sandbox.watchLogs('sb', { followLogs: false })) { + // drain + } + expect(req.followLogs).toBe(true); + expect(req.followEvents).toBe(false); + }); + + it('respects an explicit followLogs: false when followEvents is also explicitly true', async () => { + let req: { followLogs?: boolean; followEvents?: boolean } = {}; + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + // biome-ignore lint/correctness/useYield: mock only captures the request, no events to yield + watchSandbox: async function* (r) { + req = r; + }, + }); + + for await (const _event of sandbox.watchLogs('sb', { followLogs: false, followEvents: true })) { + // drain + } + expect(req.followLogs).toBe(false); + expect(req.followEvents).toBe(true); + }); + + it('resumes with the high-water-mark cursor after a reconnect, ignoring out-of-order arrival', async () => { + const requests: Array<{ resumeAfterCursor?: string }> = []; + let call = 0; + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + watchSandbox: async function* (req) { + requests.push(req); + call++; + if (call === 1) { + yield { + payload: { + case: 'log', + value: { sandboxId: 'sb', level: 'INFO', target: 't', message: 'm1', source: '', fields: {} }, + }, + cursor: 'b', + }; + yield { + payload: { + case: 'event', + value: { source: 's', type: 'Normal', reason: 'r', message: 'm2', metadata: {} }, + }, + cursor: 'a', + }; + throw new ConnectError('dropped', Code.Unavailable); + } + // call 2: clean end, nothing to replay. + }, + }); + + const events: WatchEvent[] = []; + for await (const event of sandbox.watchLogs('sb')) events.push(event); + + expect(requests).toHaveLength(2); + expect(requests[0].resumeAfterCursor).toBe(''); + // 'b' > 'a' lexicographically: the high-water mark, not the last-seen cursor. + expect(requests[1].resumeAfterCursor).toBe('b'); + expect(events).toHaveLength(2); + }); + + it('a warning does not advance or reset the resume cursor', async () => { + const requests: Array<{ resumeAfterCursor?: string }> = []; + let call = 0; + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + watchSandbox: async function* (req) { + requests.push(req); + call++; + if (call === 1) { + yield { + payload: { + case: 'log', + value: { sandboxId: 'sb', level: 'INFO', target: 't', message: 'm1', source: '', fields: {} }, + }, + cursor: 'c1', + }; + yield { payload: { case: 'warning', value: { message: 'lagged' } }, cursor: '' }; + throw new ConnectError('dropped', Code.Unavailable); + } + }, + }); + + for await (const _event of sandbox.watchLogs('sb')) { + // drain + } + expect(requests[1].resumeAfterCursor).toBe('c1'); + }); + + it('doubles the backoff delay on repeated drops and resets it after a delivered event', async () => { + const delays: number[] = []; + __setWatchLogsBackoffSleepForTests(async (delayMs) => { + delays.push(delayMs); + }); + let call = 0; + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + watchSandbox: async function* () { + call++; + if (call <= 3) throw new ConnectError('dropped', Code.Unavailable); + if (call === 4) { + yield { + payload: { + case: 'log', + value: { sandboxId: 'sb', level: 'INFO', target: 't', message: 'm', source: '', fields: {} }, + }, + cursor: 'c1', + }; + throw new ConnectError('dropped again', Code.Unavailable); + } + // call 5: clean end. + }, + }); + + for await (const _event of sandbox.watchLogs('sb')) { + // drain + } + // Three drops before any delivery double the delay; a delivered event + // between the fourth and fifth calls resets it back to the initial value. + expect(delays).toEqual([100, 200, 400, 100]); + }); + + it('caps the backoff delay at 2000ms', async () => { + const delays: number[] = []; + __setWatchLogsBackoffSleepForTests(async (delayMs) => { + delays.push(delayMs); + }); + let call = 0; + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + // biome-ignore lint/correctness/useYield: mock only drops the stream, no events to yield + watchSandbox: async function* () { + call++; + if (call <= 7) throw new ConnectError('dropped', Code.Unavailable); + // call 8: clean end. + }, + }); + + for await (const _event of sandbox.watchLogs('sb')) { + // drain + } + expect(delays).toEqual([100, 200, 400, 800, 1600, 2000, 2000]); + }); + + it('a caller abort during backoff stops the loop without redialing', async () => { + const controller = new AbortController(); + let calls = 0; + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + // biome-ignore lint/correctness/useYield: mock only drops the stream, no events to yield + watchSandbox: async function* () { + calls++; + throw new ConnectError('dropped', Code.Unavailable); + }, + }); + setTimeout(() => controller.abort(), 10); + await expect( + (async () => { + for await (const _event of sandbox.watchLogs('sb', { signal: controller.signal })) { + // drain + } + })(), + ).rejects.toBeInstanceOf(Error); + expect(calls).toBe(1); + }); + + it('terminates on OUT_OF_RANGE without retrying', async () => { + let calls = 0; + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + // biome-ignore lint/correctness/useYield: mock only terminates the stream, no events to yield + watchSandbox: async function* () { + calls++; + throw new ConnectError('gap', Code.OutOfRange); + }, + }); + await expect( + (async () => { + for await (const _event of sandbox.watchLogs('sb')) { + // drain + } + })(), + ).rejects.toMatchObject({ code: 'out_of_range' }); + expect(calls).toBe(1); + }); + + it('terminates on any other non-Unavailable error without retrying', async () => { + let calls = 0; + const sandbox = client({ + getSandbox: () => readySandbox('sb', 'sb-id-1'), + // biome-ignore lint/correctness/useYield: mock only terminates the stream, no events to yield + watchSandbox: async function* () { + calls++; + throw new ConnectError('bad cursor', Code.InvalidArgument); + }, + }); + await expect( + (async () => { + for await (const _event of sandbox.watchLogs('sb')) { + // drain + } + })(), + ).rejects.toMatchObject({ code: 'invalid_config' }); + expect(calls).toBe(1); + }); + + it('surfaces a not_found preflight failure before dialing watchSandbox', async () => { + let calls = 0; + const sandbox = client({ + getSandbox: () => { + throw new ConnectError('missing', Code.NotFound); + }, + // biome-ignore lint/correctness/useYield: mock only counts calls, no events to yield + watchSandbox: async function* () { + calls++; + }, + }); + await expect( + (async () => { + for await (const _event of sandbox.watchLogs('sb')) { + // drain + } + })(), + ).rejects.toMatchObject({ code: 'not_found' }); + expect(calls).toBe(0); + }); +}); + describe('create', () => { it('sends create-time service exposures', async () => { let created: { diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index e9537e1125..db281a892f 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -15,10 +15,17 @@ import type { AddressInfo } from 'node:net'; import * as net from 'node:net'; import type { MessageInitShape } from '@bufbuild/protobuf'; import { durationFromMs } from '@bufbuild/protobuf/wkt'; -import { type CallOptions, type Client, createClient, type Transport } from '@connectrpc/connect'; +import { type CallOptions, type Client, Code, createClient, type Transport } from '@connectrpc/connect'; import { errorCode, fromConnect, SdkError } from './errors.js'; import type { Provider, WorkspaceSelectorSchema } from './gen/datamodel_pb.js'; -import type { Sandbox, SandboxWorkloadTemplate, UpdateConfigResponse } from './gen/openshell_pb.js'; +import type { + Sandbox, + SandboxLogLine, + SandboxStreamEvent, + SandboxWorkloadTemplate, + UpdateConfigResponse, + PlatformEvent as WirePlatformEvent, +} from './gen/openshell_pb.js'; import { type ExecSandboxInputSchema, OpenShell, @@ -43,6 +50,14 @@ function durationFromSeconds(seconds: number) { return seconds === 0 ? undefined : durationFromMs(seconds * 1000); } +// Distinct from timestampMillis(): LogLine/PlatformEvent timestamps are +// always-present numbers that default to 0 when absent or unrepresentable, +// not an optional string field like expiresAtMs. +function eventTimeMillis(timestamp: { seconds: bigint; nanos: number } | undefined): number { + if (!timestamp) return 0; + return Number(timestamp.seconds * 1000n + BigInt(Math.trunc(timestamp.nanos / 1_000_000))); +} + function timestampMillis(timestamp: { seconds: bigint; nanos: number } | undefined): string | undefined { if (!timestamp) return undefined; const millis = timestamp.seconds * 1000n + BigInt(Math.trunc(timestamp.nanos / 1_000_000)); @@ -366,6 +381,80 @@ export interface ExecInteractiveSessionControl extends ExecInteractiveSession { readonly exitCode: number | undefined; } +// ---- watchLogs -------------------------------------------------------- + +/** A curated log line delivered by `watchLogs`. */ +export interface LogLine { + sandboxId: string; + /** Milliseconds since epoch. 0 if the wire timestamp was absent or unrepresentable. */ + timestampMs: number; + level: string; + target: string; + message: string; + /** Log source (e.g. `'gateway'`, `'sandbox'`). An empty wire value is normalized to `'gateway'`. */ + source: string; + fields: Record; +} + +/** A curated platform (compute-backend) event delivered by `watchLogs`. */ +export interface PlatformEvent { + /** Milliseconds since epoch. 0 if the wire timestamp was absent or unrepresentable. */ + timestampMs: number; + source: string; + type: string; + reason: string; + message: string; + metadata: Record; +} + +// WatchLogEvent and WatchPlatformEvent both carry `cursor`, so — unlike +// ExecStreamEvent's distinctly-named-field trick — discrimination needs an +// explicit `kind` tag. +export interface WatchLogEvent { + kind: 'log'; + line: LogLine; + /** Opaque high-water-mark cursor. Feed back as `WatchOptions.resumeAfterCursor` to resume. */ + cursor: string; +} + +export interface WatchPlatformEvent { + kind: 'event'; + event: PlatformEvent; + /** Opaque high-water-mark cursor. Feed back as `WatchOptions.resumeAfterCursor` to resume. */ + cursor: string; +} + +/** Recoverable server-side loss notice. The stream continues; never advances the cursor. */ +export interface WatchWarningEvent { + kind: 'warning'; + message: string; +} + +/** One item from `watchLogs`. Discriminate on `kind`. */ +export type WatchEvent = WatchLogEvent | WatchPlatformEvent | WatchWarningEvent; + +export interface WatchOptions extends SandboxWorkspaceOptions { + /** Stream server/supervisor log lines. Defaults to `true` when neither this nor `followEvents` is set. */ + followLogs?: boolean; + /** Stream platform (compute-backend) events. */ + followEvents?: boolean; + /** Restrict to these log sources (e.g. `'gateway'`, `'sandbox'`). Omit or empty for no filter. */ + logSources?: string[]; + /** Minimum log level to include (e.g. `'INFO'`, `'WARN'`, `'ERROR'`). Omit for no filter. */ + logMinLevel?: string; + /** + * Opaque cursor to resume after, taken from a previous `WatchEvent`'s `cursor`. + * Omit or leave empty to start from the tail. Do not construct or parse one. + */ + resumeAfterCursor?: string; + /** Replay the last N log lines (best-effort) before following. */ + logTailLines?: number; + /** Replay the last N platform events (best-effort) before following. */ + eventTail?: number; + /** Abort the watch (including any pending reconnect backoff) early. */ + signal?: AbortSignal; +} + /** Cancellation for the poll-based wait helpers. */ export interface WaitOptions extends SandboxWorkspaceOptions { /** Abort the wait (and the in-flight poll RPC) early. */ @@ -699,6 +788,99 @@ function waitSleep(delayMs: number, deadline: number, signal?: AbortSignal): Pro }); } +// Reconnect backoff for watchLogs: uncapped deadline, just a capped delay +// interruptible by the caller signal. Distinct from waitSleep, which is +// deadline-bounded for poll loops. +function defaultWatchLogsBackoffSleep(delayMs: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, delayMs); + const onAbort = (): void => { + clearTimeout(timer); + reject(new SdkError('canceled', 'watchLogs aborted')); + }; + if (signal) signal.addEventListener('abort', onAbort, { once: true }); + }); +} + +let watchLogsBackoffSleep: (delayMs: number, signal?: AbortSignal) => Promise = defaultWatchLogsBackoffSleep; + +/** + * Test-only seam for the `watchLogs` reconnect backoff delay. Not part of the + * public API surface — not re-exported from index.ts. Call with no argument + * to restore the real timer-based sleep. + */ +export function __setWatchLogsBackoffSleepForTests( + fn?: (delayMs: number, signal?: AbortSignal) => Promise, +): void { + watchLogsBackoffSleep = fn ?? defaultWatchLogsBackoffSleep; +} + +// Only Unavailable (connection drop, gateway restart) is retryable. Every +// other status — including out_of_range — is terminal so the caller sees it +// instead of the loop silently restarting from an empty cursor and hiding a gap. +function isRetryableWatchLogsError(err: SdkError): boolean { + return err.connectCode === Code.Unavailable; +} + +function toLogLine(value: SandboxLogLine): LogLine { + return { + sandboxId: value.sandboxId, + timestampMs: eventTimeMillis(value.eventTime), + level: value.level, + target: value.target, + message: value.message, + source: value.source || 'gateway', + fields: { ...value.fields }, + }; +} + +function toPlatformEvent(value: WirePlatformEvent): PlatformEvent { + return { + timestampMs: eventTimeMillis(value.eventTime), + source: value.source, + type: value.type, + reason: value.reason, + message: value.message, + metadata: { ...value.metadata }, + }; +} + +// Converts one wire watch event into a curated WatchEvent, returning the +// (possibly advanced) cursor alongside it. `cursor` is a high-water mark, not +// "last seen": the gateway reads the log and platform sources independently +// during live delivery, so arrival order can differ from cursor order. Taking +// the max keeps the resume point monotonic; assigning directly would let a +// later lower-cursor event rewind it and replay already-delivered events after +// a reconnect. The comparison is a plain byte-wise string compare on an opaque +// token — well defined only because both cursors come from the same stream. +function convertWatchEvent(wireEvent: SandboxStreamEvent, cursor: string): { event?: WatchEvent; cursor: string } { + switch (wireEvent.payload.case) { + case 'log': { + const next = wireEvent.cursor > cursor ? wireEvent.cursor : cursor; + return { + event: { kind: 'log', line: toLogLine(wireEvent.payload.value), cursor: wireEvent.cursor }, + cursor: next, + }; + } + case 'event': { + const next = wireEvent.cursor > cursor ? wireEvent.cursor : cursor; + return { + event: { kind: 'event', event: toPlatformEvent(wireEvent.payload.value), cursor: wireEvent.cursor }, + cursor: next, + }; + } + case 'warning': + return { event: { kind: 'warning', message: wireEvent.payload.value.message }, cursor }; + default: + // Sandbox snapshots and draft-policy updates are not part of this + // curated log/event stream. + return { cursor }; + } +} + // Wait for a socket to drain before writing more. Resolves on 'drain', and // also on 'close'/'error' so a pending await never leaks when the socket is // torn down mid-backpressure; short-circuits if it is already gone. @@ -1262,6 +1444,87 @@ export class SandboxClient { }; } + /** + * Watch a sandbox's log lines and platform events with loss-aware resume. + * + * Reconnects transparently on a transient stream drop, resuming from the + * highest cursor already delivered. A recoverable server-side lag surfaces + * as a `{ kind: 'warning' }` item and the stream continues. + * + * An `out_of_range` `SdkError` is terminal and deliberately not retried: it + * means the resume point is gone (trimmed from the buffer, or issued by a + * cursor space the gateway no longer has), so events between it and now are + * unrecoverable. Auto-restarting from scratch would hide that loss, which is + * exactly what this method exists to surface. Callers who accept the gap can + * start a new watch with an empty `resumeAfterCursor`; retrying the same + * cursor fails identically. + */ + async *watchLogs(name: string, options?: WatchOptions | null): AsyncGenerator { + let ref: SandboxRef; + try { + ref = await this.get(name, { + workspace: options?.workspace, + ...(options?.signal ? { signal: options.signal } : {}), + }); + } catch (e) { + throw e instanceof SdkError ? e : fromConnect(e); + } + + const signal = options?.signal; + let followLogs = options?.followLogs ?? false; + const followEvents = options?.followEvents ?? false; + // With both resolved false — whether from an explicit `false` or from + // omitting both options — the request would stream nothing and never + // complete or error. Mirror the Go SDK's guard and default to logs, since + // that footgun is worse than this one-line divergence from Rust's bare + // pass-through default. + if (!followLogs && !followEvents) followLogs = true; + + let cursor = options?.resumeAfterCursor ?? ''; + let backoffMs = 100; + + for (;;) { + if (signal?.aborted) throw new SdkError('canceled', 'watchLogs aborted'); + + // connect-es returns this AsyncIterable synchronously and unconditionally; + // the stream only opens on first iteration, so "dial" and "drain" failures + // both land in the single catch below (unlike tonic's separate Result). + const stream = this.grpc.watchSandbox( + { + ...sandboxTarget(ref.name, options), + followStatus: false, + followLogs, + followEvents, + logTailLines: options?.logTailLines ?? 0, + eventTail: options?.eventTail ?? 0, + logSources: options?.logSources ?? [], + logMinLevel: options?.logMinLevel ?? '', + resumeAfterCursor: cursor, + }, + { signal }, + ); + + let cleanEof = true; + try { + for await (const wireEvent of stream) { + // A delivered event means the connection is healthy again; reset the + // backoff so a later drop retries promptly instead of at the cap. + backoffMs = 100; + const converted = convertWatchEvent(wireEvent, cursor); + cursor = converted.cursor; + if (converted.event) yield converted.event; + } + } catch (e) { + cleanEof = false; + const err = e instanceof SdkError ? e : fromConnect(e); + if (!isRetryableWatchLogsError(err)) throw err; + await watchLogsBackoffSleep(backoffMs, signal); + backoffMs = Math.min(backoffMs * 2, 2000); + } + if (cleanEof) break; + } + } + // TTY + stdin transport half of an interactive exec. The first client frame // is the `start` variant carrying the exec request; subsequent frames are // `stdin`/`resize`. No terminal glue: raw mode, signal forwarding, and diff --git a/sdk/typescript/src/errors.test.ts b/sdk/typescript/src/errors.test.ts index cad7cb9763..a0c296dfc7 100644 --- a/sdk/typescript/src/errors.test.ts +++ b/sdk/typescript/src/errors.test.ts @@ -50,6 +50,7 @@ describe('fromConnect', () => { [Code.InvalidArgument, 'invalid_config'], [Code.Unauthenticated, 'auth'], [Code.PermissionDenied, 'auth'], + [Code.OutOfRange, 'out_of_range'], [Code.Internal, 'rpc'], ]; diff --git a/sdk/typescript/src/errors.ts b/sdk/typescript/src/errors.ts index a37bf15a89..6db56df8a8 100644 --- a/sdk/typescript/src/errors.ts +++ b/sdk/typescript/src/errors.ts @@ -32,6 +32,7 @@ export type SdkErrorCode = | 'already_exists' | 'aborted' | 'canceled' + | 'out_of_range' | 'rpc'; /** Extra context attached to an SdkError raised from a Connect RPC. */ @@ -99,6 +100,8 @@ export function fromConnect(err: unknown): SdkError { case Code.Unauthenticated: case Code.PermissionDenied: return new SdkError('auth', ce.rawMessage, options); + case Code.OutOfRange: + return new SdkError('out_of_range', ce.rawMessage, options); default: return new SdkError('rpc', ce.rawMessage, options); } diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index e4ac061518..4948c13623 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -22,7 +22,9 @@ export type { Health, HealthStatus, ListOptions, + LogLine, Page, + PlatformEvent, PolicySourceName, ProviderChange, ProviderChangeOptions, @@ -51,6 +53,11 @@ export type { UpdateConfigResult, WaitDeletedOptions, WaitOptions, + WatchEvent, + WatchLogEvent, + WatchOptions, + WatchPlatformEvent, + WatchWarningEvent, WorkspaceListScope, } from './client.js'; export { From 3f12bdc63f4daf7752647bc0acb1d71c6f32ae0b Mon Sep 17 00:00:00 2001 From: Eric Curtin Date: Tue, 13 Oct 2026 00:01:58 +0000 Subject: [PATCH 31/32] fix(server): audit proposal rejections in the gateway (#4281) * fix(server): audit proposal rejections in the gateway Signed-off-by: Eric Curtin * fix(ocsf): show rejection details in CONFIG shorthand Signed-off-by: Eric Curtin * fix(server): attribute rejection audits to operator principals Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --------- Signed-off-by: Eric Curtin Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> Co-authored-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- crates/openshell-ocsf/src/format/shorthand.rs | 38 ++- crates/openshell-ocsf/src/lib.rs | 4 + crates/openshell-ocsf/src/sanitize.rs | 53 ++++ crates/openshell-server/src/auth/principal.rs | 39 +++ crates/openshell-server/src/grpc/policy.rs | 253 ++++++++++++++++-- .../src/policy_local.rs | 44 +-- docs/how-it-works/policies/advisor.mdx | 6 +- 7 files changed, 371 insertions(+), 66 deletions(-) create mode 100644 crates/openshell-ocsf/src/sanitize.rs diff --git a/crates/openshell-ocsf/src/format/shorthand.rs b/crates/openshell-ocsf/src/format/shorthand.rs index 66e1f1c3d1..c31260bea8 100644 --- a/crates/openshell-ocsf/src/format/shorthand.rs +++ b/crates/openshell-ocsf/src/format/shorthand.rs @@ -469,7 +469,8 @@ impl OcsfEvent { // Bracketed suffix carries the structured provenance fields a // reviewer needs to scan a CONFIG audit line. Auto-approval // emits `auto`/`source`/`prover_delta`; every config change - // also carries `policy_version` and `policy_hash`. Order is + // also carries `policy_version` and `policy_hash`. Rejections + // add `chunk_id`, `rejection_reason`, and `actor`. Order is // stable so logs are greppable. let suffix = e .base @@ -479,18 +480,21 @@ impl OcsfEvent { let mut parts: Vec = Vec::new(); let mut push = |key: &str| { if let Some(value) = u.get(key).and_then(|v| v.as_str()) { - parts.push(format!("{key}:{value}")); + parts.push(format!("{key}:{}", escape_context_field(value))); } }; push("auto"); push("source"); push("prover_delta"); push("resolved_from"); + push("chunk_id"); + push("rejection_reason"); + push("actor"); if let Some(ver) = u.get("policy_version").and_then(|v| v.as_str()) { - parts.push(format!("version:{ver}")); + parts.push(format!("version:{}", escape_context_field(ver))); } if let Some(hash) = u.get("policy_hash").and_then(|v| v.as_str()) { - parts.push(format!("hash:{hash}")); + parts.push(format!("hash:{}", escape_context_field(hash))); } if parts.is_empty() { String::new() @@ -1244,6 +1248,32 @@ mod tests { ); } + /// Rejection audit events carry the chunk, reason, and deciding actor. + /// Reviewer-typed text must not be able to close the bracketed suffix. + #[test] + fn test_config_state_change_shorthand_includes_rejection_fields() { + let mut b = base(5019, "Device Config State Change", 5, "Discovery", 1, "Log"); + b.set_message("gateway rejected draft chunk chunk-1"); + b.add_unmapped("chunk_id", serde_json::json!("chunk-1")); + b.add_unmapped("rejection_reason", serde_json::json!("too broad] [x")); + b.add_unmapped("actor", serde_json::json!("user:reviewer")); + + let event = OcsfEvent::DeviceConfigStateChange(DeviceConfigStateChangeEvent { + base: b, + state: Some(StateId::Other), + state_custom_label: Some("REJECTED".to_string()), + security_level: None, + prev_security_level: None, + }); + + assert_eq!( + event.format_shorthand(), + "CONFIG:REJECTED [INFO] gateway rejected draft chunk chunk-1 \ + [chunk_id:chunk-1 rejection_reason:too\\u{20}broad\\]\\u{20}\\[x \ + actor:user:reviewer]" + ); + } + #[test] fn test_base_event_shorthand() { let mut b = base(0, "Base Event", 0, "Uncategorized", 99, "Other"); diff --git a/crates/openshell-ocsf/src/lib.rs b/crates/openshell-ocsf/src/lib.rs index ba8eb70f43..e8a6ec77de 100644 --- a/crates/openshell-ocsf/src/lib.rs +++ b/crates/openshell-ocsf/src/lib.rs @@ -30,6 +30,7 @@ pub mod enums; pub mod events; pub mod format; pub mod objects; +pub mod sanitize; pub mod tracing_layers; #[cfg(any(test, feature = "test-support"))] @@ -55,6 +56,9 @@ pub use objects::{ Product, Remediation, Tactic, Technique, Url, }; +// --- Sanitizers --- +pub use sanitize::sanitize_reason_for_audit; + // --- Builders --- pub use builders::{ ApiActivityBuilder, AppLifecycleBuilder, BaseEventBuilder, ConfigStateChangeBuilder, diff --git a/crates/openshell-ocsf/src/sanitize.rs b/crates/openshell-ocsf/src/sanitize.rs new file mode 100644 index 0000000000..06a8126dc9 --- /dev/null +++ b/crates/openshell-ocsf/src/sanitize.rs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Sanitizers for free-form text that lands in OCSF events. + +/// Sanitize a free-form reviewer-typed string before it lands in the OCSF +/// audit surface. Callers keep the raw text for their own consumers; this is +/// audit-side defense only. +/// +/// Strips control characters (except space) and caps the length, marking +/// truncation with an ellipsis. +#[must_use] +pub fn sanitize_reason_for_audit(raw: &str) -> String { + const MAX_CHARS: usize = 200; + let cleaned: String = raw + .chars() + .filter(|c| !c.is_control() || *c == ' ') + .take(MAX_CHARS) + .collect(); + if raw.chars().count() > MAX_CHARS { + format!("{cleaned}…") + } else { + cleaned + } +} + +#[cfg(test)] +mod tests { + use super::sanitize_reason_for_audit; + + #[test] + fn sanitize_reason_for_audit_strips_control_chars_and_caps_length() { + // Tabs and newlines are stripped; ordinary printable chars survive; + // multi-byte characters count as one char in the cap. + let raw = "line one\nline\ttwo\u{0001}\u{0007}"; + let cleaned = sanitize_reason_for_audit(raw); + assert!(!cleaned.contains('\n')); + assert!(!cleaned.contains('\t')); + assert!(!cleaned.contains('\u{0001}')); + assert!(cleaned.contains("line one")); + assert!(cleaned.contains("linetwo")); + + // Length cap with ellipsis marker so a downstream reader can tell + // the audit string is truncated. + let long: String = "x".repeat(500); + let capped = sanitize_reason_for_audit(&long); + assert!(capped.chars().count() <= 201); + assert!(capped.ends_with('…')); + + // Empty input maps to empty output (caller renders "(no guidance)"). + assert_eq!(sanitize_reason_for_audit(""), ""); + } +} diff --git a/crates/openshell-server/src/auth/principal.rs b/crates/openshell-server/src/auth/principal.rs index 7a4a316d62..15c676cf7b 100644 --- a/crates/openshell-server/src/auth/principal.rs +++ b/crates/openshell-server/src/auth/principal.rs @@ -109,3 +109,42 @@ pub enum SandboxIdentitySource { runtime_identity: String, }, } + +impl Principal { + /// One-line caller label for audit records. + #[must_use] + pub fn audit_actor(&self) -> String { + match self { + Self::User(user) => format!("user:{}", user.identity.subject), + Self::Operator(operator) => format!("operator:{}", operator.identity.subject), + Self::Sandbox(sandbox) => format!("sandbox:{}", sandbox.sandbox_id), + Self::Peer(peer) => format!("peer:{}", peer.replica_id), + Self::Anonymous => "anonymous".to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::identity::IdentityProvider; + + #[test] + fn audit_actor_distinguishes_operator_from_user_with_same_subject() { + let identity = Identity { + subject: "reviewer".to_string(), + display_name: None, + roles: vec![], + scopes: vec![], + provider: IdentityProvider::Mtls, + }; + let operator = Principal::Operator(OperatorPrincipal { + identity: identity.clone(), + certificate_sha256: "certificate-fingerprint".to_string(), + }); + let user = Principal::User(UserPrincipal { identity }); + + assert_eq!(operator.audit_actor(), "operator:reviewer"); + assert_eq!(user.audit_actor(), "user:reviewer"); + } +} diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 6de4dc3a7d..6d6688abae 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -74,7 +74,9 @@ use openshell_core::{ host_pattern::{host_matches, host_patterns_overlap}, settings::{self, SettingValueKind}, }; -use openshell_ocsf::{ConfigStateChangeBuilder, OcsfEvent, SeverityId, StateId, StatusId}; +use openshell_ocsf::{ + ConfigStateChangeBuilder, OcsfEvent, SeverityId, StateId, StatusId, sanitize_reason_for_audit, +}; use openshell_policy::{ L7BinaryScope, L7RuleTarget, PolicyMergeOp, ProviderPolicyLayer, canonicalize_advisor_add_rule, compose_effective_policy, merge_policy, policy_covers_rule, serialize_sandbox_policy, @@ -313,6 +315,36 @@ fn emit_gateway_policy_auto_approve_audit_log( )); } +/// Actor recorded when the gateway rejects a chunk without a reviewer. +const AUTO_REJECT_ACTOR: &str = "gateway:auto"; + +/// Emit `CONFIG:REJECTED` once a chunk is durably rejected, whether a +/// reviewer or the gateway decided. Unlike the sandbox `/wait` event, this +/// fires whether or not anyone is watching the chunk. +fn emit_gateway_policy_reject_audit_log( + sandbox: &Sandbox, + chunk: &DraftChunkRecord, + reason: &str, + actor: &str, +) { + let summary = summarize_draft_chunk_rule(chunk) + .unwrap_or_else(|_| format!("rule_name:{}", chunk.rule_name)); + let extra = [ + ("chunk_id", chunk.id.clone()), + ("rejection_reason", sanitize_reason_for_audit(reason)), + ("actor", actor.to_string()), + ]; + openshell_ocsf::ocsf_emit!(build_gateway_policy_audit_event( + sandbox.object_id(), + sandbox.object_name(), + "rejected", + format!("gateway rejected draft chunk {}: {summary}", chunk.id), + 0, + "", + &extra, + )); +} + fn build_gateway_policy_audit_event( sandbox_id: &str, sandbox_name: &str, @@ -1313,7 +1345,7 @@ async fn refresh_pending_chunk_evaluation( /// failing this cleanup pass should not abort the submission flow. async fn supersede_other_pending_chunks_for_endpoint( state: &Arc, - sandbox_id: &str, + sandbox: &Sandbox, new_chunk_id: &str, host: &str, port: i32, @@ -1324,6 +1356,7 @@ async fn supersede_other_pending_chunks_for_endpoint( if host.is_empty() || port == 0 || binary.is_empty() { return; } + let sandbox_id = sandbox.object_id(); let pending = match state .store @@ -1367,6 +1400,7 @@ async fn supersede_other_pending_chunks_for_endpoint( binary = %binary, "Auto-rejected pending chunk: superseded by newer submission for same (host, port, binary)" ); + emit_gateway_policy_reject_audit_log(sandbox, &other, &reason, AUTO_REJECT_ACTOR); } Err(err) => { warn!( @@ -1388,7 +1422,7 @@ async fn supersede_other_pending_chunks_for_endpoint( /// open for refinement. Only the mechanistic side is asymmetric. async fn self_reject_mechanistic_if_already_covered( state: &Arc, - sandbox_id: &str, + sandbox: &Sandbox, new_chunk_id: &str, host: &str, port: i32, @@ -1397,6 +1431,7 @@ async fn self_reject_mechanistic_if_already_covered( if host.is_empty() || port == 0 || binary.is_empty() { return; } + let sandbox_id = sandbox.object_id(); // `put_draft_chunk` dedups mechanistic submissions on `(host, port, // binary)` and returns the id of whatever row now owns that key. On a @@ -1407,8 +1442,8 @@ async fn self_reject_mechanistic_if_already_covered( // this path never un-merges the rule (unlike the human reject handler), // the live policy would keep enforcing an access the ledger now reports as // revoked. - match state.store.get_draft_chunk(new_chunk_id).await { - Ok(Some(chunk)) if chunk.status == "pending" => {} + let incoming = match state.store.get_draft_chunk(new_chunk_id).await { + Ok(Some(chunk)) if chunk.status == "pending" => chunk, Ok(_) => return, Err(err) => { warn!( @@ -1419,7 +1454,7 @@ async fn self_reject_mechanistic_if_already_covered( ); return; } - } + }; let approved = match state .store @@ -1468,6 +1503,7 @@ async fn self_reject_mechanistic_if_already_covered( binary = %binary, "Auto-rejected incoming mechanistic chunk: endpoint already covered by an approved chunk" ); + emit_gateway_policy_reject_audit_log(sandbox, &incoming, &reason, AUTO_REJECT_ACTOR); } Ok(false) => { info!( @@ -5456,7 +5492,7 @@ pub(super) async fn handle_submit_policy_analysis( // out the relationship by structural overlap. supersede_other_pending_chunks_for_endpoint( state, - &sandbox_id, + &sandbox, &effective_id, &record.host, record.port, @@ -5473,7 +5509,7 @@ pub(super) async fn handle_submit_policy_analysis( if req.analysis_mode == "mechanistic" { self_reject_mechanistic_if_already_covered( state, - &sandbox_id, + &sandbox, &effective_id, &record.host, record.port, @@ -5839,6 +5875,7 @@ async fn handle_reject_draft_chunk_inner( .update_draft_chunk_status(&req.chunk_id, "rejected", Some(now_ms), persisted_reason) .await .map_err(|e| Status::internal(format!("update chunk status failed: {e}")))?; + emit_gateway_policy_reject_audit_log(&sandbox, &chunk, &req.reason, &principal.audit_actor()); state.sandbox_watch_bus.notify(&sandbox_id); if was_approved { @@ -20183,8 +20220,10 @@ mod tests { ); } - #[tokio::test] - async fn gateway_policy_audit_events_reach_ocsf_jsonl() { + /// Run `run` with a gateway OCSF JSONL log attached and return the events + /// it emitted. Needs the default current-thread test runtime, because the + /// subscriber is thread-local. + async fn capture_ocsf_jsonl(run: F) -> (F::Output, Vec) { use tracing_subscriber::prelude::*; let directory = tempfile::tempdir().unwrap(); @@ -20195,8 +20234,23 @@ mod tests { )) .unwrap(); let log = crate::ocsf_log::OcsfLog::start(config).unwrap(); - let subscriber = tracing_subscriber::registry().with(log.layer()); - tracing::subscriber::with_default(subscriber, || { + let guard = + tracing::subscriber::set_default(tracing_subscriber::registry().with(log.layer())); + let output = run.await; + drop(guard); + log.shutdown().await; + + let events = std::fs::read_to_string(path) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + (output, events) + } + + #[tokio::test] + async fn gateway_policy_audit_events_reach_ocsf_jsonl() { + let ((), events) = capture_ocsf_jsonl(async { emit_gateway_policy_audit_log( "sb-123", "demo-sandbox", @@ -20214,14 +20268,9 @@ mod tests { "mechanistic", "gateway", ); - }); - log.shutdown().await; + }) + .await; - let events: Vec = std::fs::read_to_string(path) - .unwrap() - .lines() - .map(|line| serde_json::from_str(line).unwrap()) - .collect(); assert_eq!(events.len(), 2); for event in &events { assert_eq!(event["metadata"]["product"]["name"], "OpenShell Gateway"); @@ -20235,6 +20284,172 @@ mod tests { assert_eq!(events[1]["unmapped"]["resolved_from"], "gateway"); } + async fn seed_ready_sandbox(state: &Arc, name: &str) { + use openshell_core::proto::{FilesystemPolicy, SandboxPhase, SandboxPolicy, SandboxSpec}; + + let mut sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: format!("sb-{name}"), + name: name.to_string(), + created_time: openshell_core::time::timestamp_from_millis(1_000_000).ok(), + labels: std::collections::HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_time: None, + }), + spec: Some(SandboxSpec { + policy: Some(SandboxPolicy { + version: 1, + filesystem: Some(FilesystemPolicy { + read_write: vec!["/sandbox".to_string()], + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }; + sandbox.set_phase(SandboxPhase::Ready as i32); + state.store.put_message(&sandbox).await.unwrap(); + } + + /// Submit one `curl` -> `example.com:8080` chunk and return its id. + async fn submit_example_chunk( + state: &Arc, + sandbox_name: &str, + analysis_mode: &str, + ) -> String { + use openshell_core::proto::{NetworkBinary, NetworkEndpoint}; + + let submit = Box::pin(handle_submit_policy_analysis( + state, + with_user(Request::new(SubmitPolicyAnalysisRequest { + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + name: sandbox_name.to_string(), + analysis_mode: analysis_mode.to_string(), + proposed_chunks: vec![PolicyChunk { + rule_name: "allow_example_8080".to_string(), + proposed_rule: Some(NetworkPolicyRule { + name: "allow_example_8080".to_string(), + endpoints: vec![NetworkEndpoint { + host: "example.com".to_string(), + port: 8080, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/curl".to_string(), + }], + }), + ..Default::default() + }], + ..Default::default() + })), + )) + .await + .unwrap() + .into_inner(); + submit.accepted_chunk_ids[0].clone() + } + + fn rejected_events(events: &[serde_json::Value]) -> Vec<&serde_json::Value> { + events + .iter() + .filter(|event| { + event["message"] + .as_str() + .is_some_and(|m| m.contains("rejected")) + }) + .collect() + } + + #[tokio::test] + async fn reviewer_reject_emits_config_rejected_without_wait() { + let state = test_server_state().await; + seed_ready_sandbox(&state, "reject-audit").await; + let chunk_id = submit_example_chunk(&state, "reject-audit", "agent_authored").await; + + let (result, events) = capture_ocsf_jsonl(Box::pin(handle_reject_draft_chunk( + &state, + authed_request(RejectDraftChunkRequest { + request_id: String::new(), + sandbox: "reject-audit".to_string(), + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + chunk_id: chunk_id.clone(), + reason: "too broad\nscope it down".to_string(), + }), + ))) + .await; + result.unwrap(); + + let rejected = rejected_events(&events); + assert_eq!(rejected.len(), 1, "events: {events:?}"); + let event = rejected[0]; + assert_eq!(event["container"]["uid"], "sb-reject-audit"); + assert_eq!(event["unmapped"]["chunk_id"], chunk_id); + assert_eq!( + event["unmapped"]["rejection_reason"], + "too broadscope it down" + ); + assert_eq!(event["unmapped"]["actor"], "user:dev-user"); + assert!( + event["message"] + .as_str() + .unwrap() + .contains("example.com:8080"), + "message should name the rule: {event}" + ); + } + + #[tokio::test] + async fn superseded_chunk_emits_config_rejected_from_gateway() { + let state = test_server_state().await; + seed_ready_sandbox(&state, "supersede-audit").await; + let older = submit_example_chunk(&state, "supersede-audit", "mechanistic").await; + + let (newer, events) = capture_ocsf_jsonl(submit_example_chunk( + &state, + "supersede-audit", + "agent_authored", + )) + .await; + + let rejected = rejected_events(&events); + assert_eq!(rejected.len(), 1, "events: {events:?}"); + let event = rejected[0]; + assert_eq!(event["unmapped"]["chunk_id"], older); + assert_eq!( + event["unmapped"]["rejection_reason"], + format!("superseded by chunk {newer}") + ); + assert_eq!(event["unmapped"]["actor"], AUTO_REJECT_ACTOR); + } + + #[tokio::test] + async fn covered_mechanistic_chunk_emits_config_rejected_from_gateway() { + let state = test_server_state().await; + seed_ready_sandbox(&state, "covered-audit").await; + seed_sandbox_approval_mode(&state, "covered-audit", "auto").await; + let approved = submit_example_chunk(&state, "covered-audit", "agent_authored").await; + + let (incoming, events) = + capture_ocsf_jsonl(submit_example_chunk(&state, "covered-audit", "mechanistic")).await; + + let rejected = rejected_events(&events); + assert_eq!(rejected.len(), 1, "events: {events:?}"); + let event = rejected[0]; + assert_eq!(event["unmapped"]["chunk_id"], incoming); + assert!( + event["unmapped"]["rejection_reason"] + .as_str() + .unwrap() + .contains(&approved), + "reason should name the covering chunk: {event}" + ); + assert_eq!(event["unmapped"]["actor"], AUTO_REJECT_ACTOR); + } + #[test] fn summarize_cli_policy_merge_op_formats_rest_allow_rules() { let operation = PolicyMergeOp::AddAllowRules { diff --git a/crates/openshell-supervisor-network/src/policy_local.rs b/crates/openshell-supervisor-network/src/policy_local.rs index ac59b7e3c5..73c855c5fd 100644 --- a/crates/openshell-supervisor-network/src/policy_local.rs +++ b/crates/openshell-supervisor-network/src/policy_local.rs @@ -9,7 +9,9 @@ use openshell_core::proto::{ L7Allow, L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, PolicyChunk, SandboxPolicy as ProtoSandboxPolicy, }; -use openshell_ocsf::{ConfigStateChangeBuilder, SeverityId, StateId, StatusId, ocsf_emit}; +use openshell_ocsf::{ + ConfigStateChangeBuilder, SeverityId, StateId, StatusId, ocsf_emit, sanitize_reason_for_audit, +}; use serde::Deserialize; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -671,23 +673,6 @@ fn emit_policy_decision_event(chunk: &PolicyChunk) { } } -/// Sanitize a free-form reviewer-typed string before it lands in the OCSF -/// audit surface. The agent still reads the raw text via the API — this is -/// audit-side defense only. -fn sanitize_reason_for_audit(raw: &str) -> String { - const MAX_CHARS: usize = 200; - let cleaned: String = raw - .chars() - .filter(|c| !c.is_control() || *c == ' ') - .take(MAX_CHARS) - .collect(); - if raw.chars().count() > MAX_CHARS { - format!("{cleaned}…") - } else { - cleaned - } -} - /// One-line audit description of a chunk's target: binary, host, port, and /// L7 method/path allows for every endpoint. Used by both the propose and /// approve/reject audit events so the trace can be grepped by endpoint @@ -2356,29 +2341,6 @@ mod tests { ); } - #[test] - fn sanitize_reason_for_audit_strips_control_chars_and_caps_length() { - // Tabs and newlines are stripped; ordinary printable chars survive; - // multi-byte characters count as one char in the cap. - let raw = "line one\nline\ttwo\u{0001}\u{0007}"; - let cleaned = sanitize_reason_for_audit(raw); - assert!(!cleaned.contains('\n')); - assert!(!cleaned.contains('\t')); - assert!(!cleaned.contains('\u{0001}')); - assert!(cleaned.contains("line one")); - assert!(cleaned.contains("linetwo")); - - // Length cap with ellipsis marker so a downstream reader can tell - // the audit string is truncated. - let long: String = "x".repeat(500); - let capped = sanitize_reason_for_audit(&long); - assert!(capped.chars().count() <= 201); - assert!(capped.ends_with('…')); - - // Empty input maps to empty output (caller renders "(no guidance)"). - assert_eq!(sanitize_reason_for_audit(""), ""); - } - #[test] fn summarize_chunk_for_audit_falls_back_to_rule_name_without_rule() { let chunk = PolicyChunk { diff --git a/docs/how-it-works/policies/advisor.mdx b/docs/how-it-works/policies/advisor.mdx index 66e04d68cd..ad14215907 100644 --- a/docs/how-it-works/policies/advisor.mdx +++ b/docs/how-it-works/policies/advisor.mdx @@ -307,8 +307,10 @@ openshell logs --since 10m Look for the denied request (`HTTP: ... DENIED`) or blocked connection (`NET:OPEN ... DENIED`), then `CONFIG:PROPOSED`, `CONFIG:APPROVED` or `CONFIG:REJECTED`, `CONFIG:LOADED`, and the retried request. `CONFIG:PROPOSED` -appears only for proposals from the agent, and `CONFIG:REJECTED` appears only -if the agent was waiting for the decision. An automatic approval's +appears only for proposals from the agent. The sandbox log shows +`CONFIG:REJECTED` only if the agent was waiting for the decision. The gateway log +(`--source gateway`) always records it, with `chunk_id`, `rejection_reason`, and +`actor`. An automatic approval's `CONFIG:APPROVED` entry includes `auto:true`, the proposal's `source` (`agent_authored`, or `mechanistic` for OpenShell's own drafts), and `resolved_from`, which shows where the approval mode setting came from. From 5e414eadde83644d7a8a27554313b74a8bd61639 Mon Sep 17 00:00:00 2001 From: Taylor Mutch Date: Tue, 13 Oct 2026 00:47:36 +0000 Subject: [PATCH 32/32] test(e2e): remove stale policy retries (#4453) Signed-off-by: Taylor Mutch --- e2e/rust/tests/forward_proxy_graphql_l7.rs | 23 +++++--------------- e2e/rust/tests/forward_proxy_l7_bypass.rs | 25 +++++++--------------- 2 files changed, 13 insertions(+), 35 deletions(-) diff --git a/e2e/rust/tests/forward_proxy_graphql_l7.rs b/e2e/rust/tests/forward_proxy_graphql_l7.rs index 2a1b06d277..5c25556cfa 100644 --- a/e2e/rust/tests/forward_proxy_graphql_l7.rs +++ b/e2e/rust/tests/forward_proxy_graphql_l7.rs @@ -217,19 +217,6 @@ def forward_persisted_get_status(hash_value): error.read() return error.code -# The sandbox may resolve policy binary symlinks shortly after the entrypoint -# starts, bumping the policy generation during the first forward request. -# Retry expected-allowed forward requests so the test still targets L7 behavior. -def retry_forward_allowed(label, request_fn): - last_status = None - for attempt in range(6): - last_status = request_fn() - if last_status != 403: - return last_status - DETAILS[f"{{label}}_transient_403_attempts"] = attempt + 1 - time.sleep(0.3) - return last_status - def forward_chunked_status(query): target = f"{{HOST}}:{{PORT}}" body = json.dumps({{"query": query}}).encode() @@ -368,14 +355,14 @@ def raw_chunked_status(query): return raw_http_status("raw_chunked_query_allowed", request) results = {{ - "forward_query_allowed": retry_forward_allowed("forward_query_allowed", lambda: forward_status(QUERY_VIEWER)), - "forward_get_query_allowed": retry_forward_allowed("forward_get_query_allowed", lambda: forward_get_status(QUERY_VIEWER)), + "forward_query_allowed": forward_status(QUERY_VIEWER), + "forward_get_query_allowed": forward_get_status(QUERY_VIEWER), "forward_duplicate_get_denied": forward_duplicate_get_status(), - "forward_persisted_get_allowed": retry_forward_allowed("forward_persisted_get_allowed", lambda: forward_persisted_get_status("abc123")), + "forward_persisted_get_allowed": forward_persisted_get_status("abc123"), "forward_unregistered_persisted_get_denied": forward_persisted_get_status("missing"), - "forward_chunked_query_allowed": retry_forward_allowed("forward_chunked_query_allowed", lambda: forward_chunked_status(QUERY_VIEWER)), + "forward_chunked_query_allowed": forward_chunked_status(QUERY_VIEWER), "forward_unlisted_field_denied": forward_status(QUERY_REPOSITORY), - "forward_mutation_allowed": retry_forward_allowed("forward_mutation_allowed", lambda: forward_status(MUTATION_CREATE)), + "forward_mutation_allowed": forward_status(MUTATION_CREATE), "forward_deny_rule_denied": forward_status(MUTATION_DELETE), "raw_query_allowed": raw_status(QUERY_VIEWER, "raw_query_allowed"), "raw_get_query_allowed": raw_get_status(QUERY_VIEWER, "raw_get_query_allowed"), diff --git a/e2e/rust/tests/forward_proxy_l7_bypass.rs b/e2e/rust/tests/forward_proxy_l7_bypass.rs index e346ac169a..d726b84721 100644 --- a/e2e/rust/tests/forward_proxy_l7_bypass.rs +++ b/e2e/rust/tests/forward_proxy_l7_bypass.rs @@ -109,24 +109,15 @@ async fn forward_proxy_allows_l7_permitted_request() { let script = format!( r#" -import urllib.request, urllib.error, json, sys, time +import urllib.request, urllib.error, json, sys url = "http://{host}:{port}/allowed" -# Retry expected-allowed requests across the startup symlink-resolution reload, -# which can transiently surface as a stale-policy 403 in the forward proxy. -last = {{"status": -1, "error": "not attempted"}} -for attempt in range(6): - try: - resp = urllib.request.urlopen(url, timeout=15) - last = {{"status": resp.status, "error": None, "attempt": attempt + 1}} - break - except urllib.error.HTTPError as e: - last = {{"status": e.code, "error": str(e), "attempt": attempt + 1}} - if e.code != 403: - break - time.sleep(0.3) - except Exception as e: - last = {{"status": -1, "error": str(e), "attempt": attempt + 1}} - break +try: + resp = urllib.request.urlopen(url, timeout=15) + last = {{"status": resp.status, "error": None}} +except urllib.error.HTTPError as e: + last = {{"status": e.code, "error": str(e)}} +except Exception as e: + last = {{"status": -1, "error": str(e)}} print(json.dumps(last)) "#, host = server.host,